fix: case-insensitive .dash + wallet concurrency hardening (UTXO and receive-address races)#3585
fix: case-insensitive .dash + wallet concurrency hardening (UTXO and receive-address races)#3585Claudius-Maginificent wants to merge 39 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 docstrings
🧪 Generate unit tests (beta)
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 b9a3be7) |
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: "ebd90e7ea9e79be1e281c0009c6de570ff8b42950dc6f5378f9df5f2493d65ac"
)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>
…-utxo-race-v3.1-dev
…-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
All four prior findings remain valid on the current head — none have been addressed by the recent merge. CoreWallet::send_to_addresses gets a solid in-process reservation/RAII guard, but IdentityWallet::send_payment still builds with set_funding without consulting the reservation set; coin-selection errors are still substring-matched (with an explicit TODO); the FFI From still flattens new typed retryable variants to ErrorUnknown; and the post-broadcast block still unconditionally drops reservations when wallet lookup or check_core_transaction no-ops. One new defensive-pointer issue found in the FFI entry point. Review action: COMMENT.
Note: Inline posting failed (command failed (1): gh api /repos/dashpay/platform/pulls/3585/reviews --method POST --input -
STDOUT:
{"message":"Unprocessable Entity","errors":["Variable $threads of type [DraftPullRequestReviewThread] was provided invalid value for 0.commitId (Field is not defined on DraftPullRequestReviewThread)), so I posted the same verified findings as a top-level review body.
🟡 5 suggestion(s) | 💬 1 nitpick(s)
6 additional finding(s)
suggestion: DashPay send_payment bypasses the new CoreWallet reservation set
packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 192)
IdentityWallet::send_payment builds via TransactionBuilder::set_funding(managed_account, account) at line 195 and never consults CoreWallet::reservations. The wallet-manager write lock is held only across the build block (released at line 206) before broadcast at lines 209-213. A concurrent CoreWallet::send_to_addresses on the same wallet reserves its outpoints, drops the wallet-manager lock to await broadcast, and then send_payment — re-acquiring the write lock during build — sees those still-reserved UTXOs as spendable in managed_account.utxos (they're only reserved, not marked spent locally) and selects the same outpoints. Both broadcasts hit the network with the same input; the network rejects the second as a double-spend but local state in send_payment has already advanced the external-account derivation index (line 167, next_address(..., true)) and recorded a PaymentEntry for a tx the network refused. This defeats the reservation-set invariant established by the PR. The same hole exists for any path that funds via set_funding outside send_to_addresses. To preserve Fix 2's contract: consult self.reservations, filter spendable_utxos against them, reserve selected outpoints via the same RAII guard, drop the lock for broadcast, and commit derivation-index advances post-broadcast — symmetric to send_to_addresses.
suggestion: Coin-selection error classification uses brittle substring matching
packages/rs-platform-wallet/src/wallet/core/broadcast.rs (line 167)
build_signed's error is downcast via e.to_string() and matched against "Insufficient funds" / "No UTXOs available" to choose between NoSpendableInputs and TransactionBuild. The TODO at line 170 acknowledges this as brittle. The pinned key-wallet revision already exposes structured BuilderError/SelectionError variants (BuilderError::InsufficientFunds, BuilderError::CoinSelection(SelectionError::NoUtxosAvailable), BuilderError::CoinSelection(SelectionError::InsufficientFunds { .. })). A future rewording of the Display impls silently flips the classification, breaking the retry-vs-fatal contract callers rely on. The only test pinning this path is the early-exit guard (builder_error_text_contract_for_no_inputs at line 818, which exercises line 137, not the build_signed mapping). At minimum, pattern-match the typed enum here and add a test that fires a real SelectionError::InsufficientFunds through build_signed.
suggestion: New retryable wallet errors collapse to ErrorUnknown at the FFI boundary
packages/rs-platform-wallet-ffi/src/error.rs (line 157)
From<PlatformWalletError> for PlatformWalletFFIResult maps every variant to PlatformWalletFFIResultCode::ErrorUnknown with error.to_string(). The PR introduces two semantically distinct retryable variants — NoSpendableInputs { account_type, account_index, context } and ConcurrentSpendConflict { selected: Vec<OutPoint> } — whose entire point is to let callers branch on them (NoSpendableInputs → wait/retry or surface depleted-wallet message; ConcurrentSpendConflict → builder regression tripwire). After this blanket conversion, the Swift PlatformWalletError(result:) dispatcher (which switches on the numeric code) can only string-match the human-readable message, which is worse than the brittle substring match the PR was trying to remove inside Rust. The ConcurrentSpendConflict.selected outpoint list — the diagnostic payload — is also lost. Give these variants dedicated PlatformWalletFFIResultCodes (e.g. ErrorNoSpendableInputs, ErrorConcurrentSpendConflict) and match them in this From impl before the blanket fall-through. The same applies to other typed variants (WalletNotFound, IdentityNotFound, WalletLocked) but the two new variants are the immediate regression.
suggestion: Reservation drops unconditionally on no-op post-broadcast paths
packages/rs-platform-wallet/src/wallet/core/broadcast.rs (line 230)
After broadcast_transaction(&tx).await? succeeds (line 228), the post-broadcast block has two paths where the outpoints are NOT transitioned from reserved → spent: (1) else at line 280, where wm.get_wallet_mut_and_info_mut(&self.wallet_id) returns None and only tracing::warn! fires; (2) if !check_result.is_relevant at line 269, where check_core_transaction did not recognize the tx and therefore did not mutate managed_account.utxos. In both cases drop(_reservation) at line 295 fires unconditionally and releases the outpoints. A subsequent send_to_addresses on the same wallet will see those outpoints back in the spendable snapshot (never marked spent locally), select them, and either build a tx the network rejects as a double-spend or — worse — legitimately spend them if the network never actually accepted the first broadcast. The comment at lines 293-294 ("inputs are already marked spent above; no gap…") holds only on the happy path. The comment at line 282 ("future sends will surface a clean WalletNotFound") is wrong when a clone of the CoreWallet (sharing reservations) continues operating after the wallet handle drops. Either gate the reservation drop on check_result.is_relevant && wallet_lookup.is_some(), or hold the reservation as sticky until a sync reconciles the spend.
suggestion: `from_raw_parts` and per-element CStr deref skip remaining safety guards
packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs (line 66)
The if count > 0 { check_ptr!(addresses); check_ptr!(amounts); } guard at lines 66-69 signals intent to accept count == 0 from Swift callers, but the subsequent std::slice::from_raw_parts(addresses, count) / std::slice::from_raw_parts(amounts, count) (lines 74-75) violate Rust's contract for from_raw_parts, which requires the data pointer be non-null and aligned even for zero-length slices. Swift's withUnsafeBufferPointer.baseAddress is documented to return nil for an empty Array, so this is reachable. Additionally, CStr::from_ptr(addr_ptrs[i]) at line 78 does not null-check each element before dereferencing, so a stray null inside the array (e.g. allocation failure in a strdup chain) is UB rather than a typed FFI error. Mitigations: short-circuit when count == 0 (or use &[] instead of from_raw_parts), and null-check addr_ptrs[i] before CStr::from_ptr.
nitpick: Peek-without-commit can produce change-address reuse across concurrent senders
packages/rs-platform-wallet/src/wallet/core/broadcast.rs (line 145)
The change-address is peeked (advance=false) under the wallet-manager write lock at line 150 and committed (advance=true) only after broadcast succeeds, at line 251. Two concurrent send_to_addresses on disjoint UTXOs (wallet has UTXO_1 and UTXO_2; A reserves UTXO_1, B reserves UTXO_2) will both peek the same next-change index N because broadcast is async network I/O performed without the write lock. Both txs go on the wire with change at index N; both then commit-advance to N+1. The result is unintentional change-address reuse and a privacy regression. The peek-then-commit pattern itself is defensible (it avoids burning indices on broadcast failure), but the comment at lines 145-148 does not acknowledge the concurrent-callers case. If preserving privacy outweighs not burning indices on failure, commit the advance under the same lock that reserves outpoints and rollback on the broadcast-failure path, or track the peeked-but-not-committed change index inside the reservations set so concurrent callers see it.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:192-213: DashPay send_payment bypasses the new CoreWallet reservation set
`IdentityWallet::send_payment` builds via `TransactionBuilder::set_funding(managed_account, account)` at line 195 and never consults `CoreWallet::reservations`. The wallet-manager write lock is held only across the build block (released at line 206) before broadcast at lines 209-213. A concurrent `CoreWallet::send_to_addresses` on the same wallet reserves its outpoints, drops the wallet-manager lock to await broadcast, and then `send_payment` — re-acquiring the write lock during build — sees those still-reserved UTXOs as spendable in `managed_account.utxos` (they're only reserved, not marked spent locally) and selects the same outpoints. Both broadcasts hit the network with the same input; the network rejects the second as a double-spend but local state in `send_payment` has already advanced the external-account derivation index (line 167, `next_address(..., true)`) and recorded a `PaymentEntry` for a tx the network refused. This defeats the reservation-set invariant established by the PR. The same hole exists for any path that funds via `set_funding` outside `send_to_addresses`. To preserve Fix 2's contract: consult `self.reservations`, filter `spendable_utxos` against them, reserve selected outpoints via the same RAII guard, drop the lock for broadcast, and commit derivation-index advances post-broadcast — symmetric to `send_to_addresses`.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:167-181: Coin-selection error classification uses brittle substring matching
`build_signed`'s error is downcast via `e.to_string()` and matched against `"Insufficient funds"` / `"No UTXOs available"` to choose between `NoSpendableInputs` and `TransactionBuild`. The TODO at line 170 acknowledges this as brittle. The pinned `key-wallet` revision already exposes structured `BuilderError`/`SelectionError` variants (`BuilderError::InsufficientFunds`, `BuilderError::CoinSelection(SelectionError::NoUtxosAvailable)`, `BuilderError::CoinSelection(SelectionError::InsufficientFunds { .. })`). A future rewording of the Display impls silently flips the classification, breaking the retry-vs-fatal contract callers rely on. The only test pinning this path is the early-exit guard (`builder_error_text_contract_for_no_inputs` at line 818, which exercises line 137, not the build_signed mapping). At minimum, pattern-match the typed enum here and add a test that fires a real `SelectionError::InsufficientFunds` through `build_signed`.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/error.rs`:157-161: New retryable wallet errors collapse to ErrorUnknown at the FFI boundary
`From<PlatformWalletError> for PlatformWalletFFIResult` maps every variant to `PlatformWalletFFIResultCode::ErrorUnknown` with `error.to_string()`. The PR introduces two semantically distinct retryable variants — `NoSpendableInputs { account_type, account_index, context }` and `ConcurrentSpendConflict { selected: Vec<OutPoint> }` — whose entire point is to let callers branch on them (NoSpendableInputs → wait/retry or surface depleted-wallet message; ConcurrentSpendConflict → builder regression tripwire). After this blanket conversion, the Swift `PlatformWalletError(result:)` dispatcher (which switches on the numeric code) can only string-match the human-readable message, which is worse than the brittle substring match the PR was trying to remove inside Rust. The `ConcurrentSpendConflict.selected` outpoint list — the diagnostic payload — is also lost. Give these variants dedicated `PlatformWalletFFIResultCode`s (e.g. `ErrorNoSpendableInputs`, `ErrorConcurrentSpendConflict`) and match them in this `From` impl before the blanket fall-through. The same applies to other typed variants (`WalletNotFound`, `IdentityNotFound`, `WalletLocked`) but the two new variants are the immediate regression.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:230-295: Reservation drops unconditionally on no-op post-broadcast paths
After `broadcast_transaction(&tx).await?` succeeds (line 228), the post-broadcast block has two paths where the outpoints are NOT transitioned from reserved → spent: (1) `else` at line 280, where `wm.get_wallet_mut_and_info_mut(&self.wallet_id)` returns `None` and only `tracing::warn!` fires; (2) `if !check_result.is_relevant` at line 269, where `check_core_transaction` did not recognize the tx and therefore did not mutate `managed_account.utxos`. In both cases `drop(_reservation)` at line 295 fires unconditionally and releases the outpoints. A subsequent `send_to_addresses` on the same wallet will see those outpoints back in the spendable snapshot (never marked spent locally), select them, and either build a tx the network rejects as a double-spend or — worse — legitimately spend them if the network never actually accepted the first broadcast. The comment at lines 293-294 ("inputs are already marked spent above; no gap…") holds only on the happy path. The comment at line 282 ("future sends will surface a clean `WalletNotFound`") is wrong when a clone of the `CoreWallet` (sharing reservations) continues operating after the wallet handle drops. Either gate the reservation drop on `check_result.is_relevant && wallet_lookup.is_some()`, or hold the reservation as sticky until a sync reconciles the spend.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs`:66-83: `from_raw_parts` and per-element CStr deref skip remaining safety guards
The `if count > 0 { check_ptr!(addresses); check_ptr!(amounts); }` guard at lines 66-69 signals intent to accept `count == 0` from Swift callers, but the subsequent `std::slice::from_raw_parts(addresses, count)` / `std::slice::from_raw_parts(amounts, count)` (lines 74-75) violate Rust's contract for `from_raw_parts`, which requires the data pointer be non-null and aligned even for zero-length slices. Swift's `withUnsafeBufferPointer.baseAddress` is documented to return `nil` for an empty `Array`, so this is reachable. Additionally, `CStr::from_ptr(addr_ptrs[i])` at line 78 does not null-check each element before dereferencing, so a stray null inside the array (e.g. allocation failure in a `strdup` chain) is UB rather than a typed FFI error. Mitigations: short-circuit when `count == 0` (or use `&[]` instead of `from_raw_parts`), and null-check `addr_ptrs[i]` before `CStr::from_ptr`.
- [NITPICK] In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:145-151: Peek-without-commit can produce change-address reuse across concurrent senders
The change-address is peeked (`advance=false`) under the wallet-manager write lock at line 150 and committed (`advance=true`) only after broadcast succeeds, at line 251. Two concurrent `send_to_addresses` on disjoint UTXOs (wallet has UTXO_1 and UTXO_2; A reserves UTXO_1, B reserves UTXO_2) will both peek the same next-change index N because broadcast is async network I/O performed without the write lock. Both txs go on the wire with change at index N; both then commit-advance to N+1. The result is unintentional change-address reuse and a privacy regression. The peek-then-commit pattern itself is defensible (it avoids burning indices on broadcast failure), but the comment at lines 145-148 does not acknowledge the concurrent-callers case. If preserving privacy outweighs not burning indices on failure, commit the advance under the same lock that reserves outpoints and rollback on the broadcast-failure path, or track the peeked-but-not-committed change index inside the reservations set so concurrent callers see it.
…afe slice marshalling CMT-002: map PlatformWalletError::NoSpendableInputs and ConcurrentSpendConflict to dedicated PlatformWalletFFIResultCode variants (30, 31) so Swift can branch on numeric codes instead of substring-matching messages. Mirrors the new cases in PlatformWalletResultCode + PlatformWalletError on the Swift side. The ConcurrentSpendConflict.selected: Vec<OutPoint> payload is still stringified into the message field (TODO: propagate structured payload when a generic FFI sidecar exists). CMT-004: harden core_wallet_send_to_addresses input marshalling. slice::from_raw_parts requires a non-null aligned pointer even for len == 0, and Swift's empty Array.withUnsafeBufferPointer.baseAddress returns nil — guard the zero-count path. Also null-check every addr_ptrs[i] before CStr::from_ptr instead of trusting the caller. Tests cover both count==0 paths (null and non-null arrays) plus a null-element-inside-array case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…x and post-broadcast paths - CMT-001: `IdentityWallet::send_payment` now consults the shared `OutpointReservations` set used by `CoreWallet::send_to_addresses`, reserving its selected outpoints + change address under the same write lock and releasing them only after `check_core_transaction` reconciles the broadcast into wallet state. A concurrent core send + DashPay send on the same wallet can no longer pick the same UTXO. - CMT-003: `send_to_addresses` no longer releases the reservation unconditionally after a successful broadcast. If the post-broadcast reconcile path fails (wallet handle stale, or own-built tx not recognised by `check_core_transaction`), the reservation is leaked until a wallet restart / full sync; releasing it would let a concurrent caller re-select an already-broadcast outpoint and produce a double-spend the network would reject. Added a unit test (`unreconciled_broadcast_keeps_reservation_held`). - CMT-006: peek-without-commit change-address reuse closed by committing the change-index advance inside the same write lock as the outpoint reservation, and skipping past any change address still pending from a concurrent in-flight send. A single index per failure is burned — acceptable; reuse is not. - CMT-005: stronger deferral comment for the brittle coin-selection substring match (typed `BuilderError::CoinSelection(SelectionError::…)` upstream). Refs: PR #3585. Co-Authored-By: Bilby the Dev <842586+lklimek@users.noreply.github.com>
|
Triage results for the 6 carry-forward findings at this PR's head — now
Triage performed via Claudius the Magnificent AI Agent. |
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Current head resolves three earlier issues in this area: DashPay now shares the core reservation set, pending change addresses are tracked to avoid reuse, and post-broadcast reconciliation no longer drops reservations on the success-but-unreconciled path. One carried-forward prior finding remains valid in send_to_addresses, and this delta adds two new suggestion-level issues: leak_until_sync makes leaked reservations permanent for the life of the process, and the duplicated DashPay send path still has no dedicated regression coverage.
🟡 3 suggestion(s)
3 additional finding(s)
suggestion: `send_to_addresses` still classifies retryable coin-selection failures by `Display` text
packages/rs-platform-wallet/src/wallet/core/broadcast.rs (line 190)
TransactionBuilder::build_signed returns a typed BuilderError, and the pinned key-wallet dependency exposes BuilderError::CoinSelection(SelectionError::InsufficientFunds|NoUtxosAvailable). This code discards that structure and decides whether to return PlatformWalletError::NoSpendableInputs by substring-matching e.to_string(). That makes the wallet's public retryable/non-retryable contract depend on upstream wording instead of the actual error variant. A wording change in key-wallet silently turns a retryable selection failure into TransactionBuild, and the nearby test explicitly notes that this mapping path is still unpinned.
suggestion: `leak_until_sync` permanently strands reservations with no in-process recovery path
packages/rs-platform-wallet/src/wallet/core/reservations.rs (line 148)
OutpointReservationGuard::leak_until_sync calls std::mem::forget(self), so the only owner capable of removing the reserved outpoints and pending change address never runs Drop. There is no API in OutpointReservations or any sync path in this crate that rebuilds or clears ReservationsInner afterward, and the test suite now pins the behavior as "held until process restart." If check_core_transaction returns a false negative or the wallet handle is missing after a successful broadcast, those outpoints and change addresses remain unavailable for the lifetime of the process, causing false NoSpendableInputs results and permanently skipped change indices until restart.
suggestion: The duplicated DashPay send path has no regression tests for its new reservation and reconciliation logic
packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 93)
IdentityWallet::send_payment now duplicates the same lock/build/reserve/broadcast/reconcile flow as CoreWallet::send_to_addresses, including shared outpoint reservations, pending-change tracking, and the sticky reservation path on unreconciled broadcasts. There is no test coverage for send_payment itself: a repo-wide search finds only the implementation and docs, not a test that exercises concurrent send_payment vs. send_to_addresses, rollback on broadcast failure, or the leak_until_sync branch. Because this logic is duplicated instead of factored into a shared helper, the existing core-wallet tests do not protect the DashPay path from regressions.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:190-205: `send_to_addresses` still classifies retryable coin-selection failures by `Display` text
`TransactionBuilder::build_signed` returns a typed `BuilderError`, and the pinned `key-wallet` dependency exposes `BuilderError::CoinSelection(SelectionError::InsufficientFunds|NoUtxosAvailable)`. This code discards that structure and decides whether to return `PlatformWalletError::NoSpendableInputs` by substring-matching `e.to_string()`. That makes the wallet's public retryable/non-retryable contract depend on upstream wording instead of the actual error variant. A wording change in `key-wallet` silently turns a retryable selection failure into `TransactionBuild`, and the nearby test explicitly notes that this mapping path is still unpinned.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/reservations.rs`:148-161: `leak_until_sync` permanently strands reservations with no in-process recovery path
`OutpointReservationGuard::leak_until_sync` calls `std::mem::forget(self)`, so the only owner capable of removing the reserved outpoints and pending change address never runs `Drop`. There is no API in `OutpointReservations` or any sync path in this crate that rebuilds or clears `ReservationsInner` afterward, and the test suite now pins the behavior as "held until process restart." If `check_core_transaction` returns a false negative or the wallet handle is missing after a successful broadcast, those outpoints and change addresses remain unavailable for the lifetime of the process, causing false `NoSpendableInputs` results and permanently skipped change indices until restart.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:93-357: The duplicated DashPay send path has no regression tests for its new reservation and reconciliation logic
`IdentityWallet::send_payment` now duplicates the same lock/build/reserve/broadcast/reconcile flow as `CoreWallet::send_to_addresses`, including shared outpoint reservations, pending-change tracking, and the sticky reservation path on unreconciled broadcasts. There is no test coverage for `send_payment` itself: a repo-wide search finds only the implementation and docs, not a test that exercises concurrent `send_payment` vs. `send_to_addresses`, rollback on broadcast failure, or the `leak_until_sync` branch. Because this logic is duplicated instead of factored into a shared helper, the existing core-wallet tests do not protect the DashPay path from regressions.
Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
I verified the wallet-related findings against b8043f9aabc26594f1bd796dc78dcaa4460a3b46. The three prior findings are still valid on this head, and there is one additional latest-delta regression in the duplicated DashPay send path: it does not preserve the new retryable NoSpendableInputs contract when transaction building fails.
🟡 4 suggestion(s)
4 additional finding(s)
suggestion: `send_to_addresses` still classifies coin-selection failures by matching `Display` text
packages/rs-platform-wallet/src/wallet/core/broadcast.rs (line 190)
send_to_addresses still converts build_signed failures into NoSpendableInputs by searching e.to_string() for "Insufficient funds" and "No UTXOs available". That is a brittle protocol boundary: the pinned key-wallet dependency exposes typed BuilderError::CoinSelection(SelectionError) and SelectionError::{NoUtxosAvailable, InsufficientFunds}, so this code is discarding stable enum variants and depending on human-readable wording instead. A harmless upstream wording change would silently reclassify the same retryable condition into TransactionBuild, breaking the API contract this PR introduced.
suggestion: `leak_until_sync` makes unreconciled reservations permanent for the lifetime of the process
packages/rs-platform-wallet/src/wallet/core/reservations.rs (line 148)
OutpointReservationGuard::leak_until_sync only calls std::mem::forget(self), so the only code that can remove the reserved outpoints and pending change address never runs. I could not find any compensating API on OutpointReservations, and the crate’s own test now pins this as "held until process restart", so the documented "full sync will reconcile" path does not exist in-process. If post-broadcast reconciliation returns a false negative or the wallet handle goes stale after broadcast, those UTXOs and change addresses remain locally blocked until restart, causing persistent NoSpendableInputs results and skipped change indices.
suggestion: The duplicated DashPay send path still has no focused regression coverage for reservation and reconciliation behavior
packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 93)
IdentityWallet::send_payment duplicates the same reserve/build/broadcast/reconcile flow as CoreWallet::send_to_addresses, including shared outpoint reservations, pending change tracking, and the leak_until_sync branch. A repo-wide search at this SHA found no tests that exercise send_payment itself for the concurrency loser case, broadcast-failure rollback, or unreconciled-success behavior. Because this logic is duplicated instead of factored behind a shared helper, the existing broadcast.rs tests do not protect the DashPay path from diverging.
suggestion: `send_payment` collapses retryable coin-selection failures into generic `TransactionBuild` errors
packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 257)
Unlike CoreWallet::send_to_addresses, the DashPay path maps every build_signed failure directly to PlatformWalletError::TransactionBuild(e.to_string()). That regresses the new wallet error contract for retryable depletion cases: a normal BuilderError::CoinSelection(SelectionError::InsufficientFunds | NoUtxosAvailable) now surfaces as a generic builder error instead of NoSpendableInputs. Callers of the two send paths therefore receive inconsistent error semantics for the same underlying failure and are forced back to string inspection on the DashPay path.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:190-205: `send_to_addresses` still classifies coin-selection failures by matching `Display` text
`send_to_addresses` still converts `build_signed` failures into `NoSpendableInputs` by searching `e.to_string()` for `"Insufficient funds"` and `"No UTXOs available"`. That is a brittle protocol boundary: the pinned `key-wallet` dependency exposes typed `BuilderError::CoinSelection(SelectionError)` and `SelectionError::{NoUtxosAvailable, InsufficientFunds}`, so this code is discarding stable enum variants and depending on human-readable wording instead. A harmless upstream wording change would silently reclassify the same retryable condition into `TransactionBuild`, breaking the API contract this PR introduced.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/reservations.rs`:148-161: `leak_until_sync` makes unreconciled reservations permanent for the lifetime of the process
`OutpointReservationGuard::leak_until_sync` only calls `std::mem::forget(self)`, so the only code that can remove the reserved outpoints and pending change address never runs. I could not find any compensating API on `OutpointReservations`, and the crate’s own test now pins this as `"held until process restart"`, so the documented `"full sync will reconcile"` path does not exist in-process. If post-broadcast reconciliation returns a false negative or the wallet handle goes stale after broadcast, those UTXOs and change addresses remain locally blocked until restart, causing persistent `NoSpendableInputs` results and skipped change indices.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:93-360: The duplicated DashPay send path still has no focused regression coverage for reservation and reconciliation behavior
`IdentityWallet::send_payment` duplicates the same reserve/build/broadcast/reconcile flow as `CoreWallet::send_to_addresses`, including shared outpoint reservations, pending change tracking, and the `leak_until_sync` branch. A repo-wide search at this SHA found no tests that exercise `send_payment` itself for the concurrency loser case, broadcast-failure rollback, or unreconciled-success behavior. Because this logic is duplicated instead of factored behind a shared helper, the existing `broadcast.rs` tests do not protect the DashPay path from diverging.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:257-262: `send_payment` collapses retryable coin-selection failures into generic `TransactionBuild` errors
Unlike `CoreWallet::send_to_addresses`, the DashPay path maps every `build_signed` failure directly to `PlatformWalletError::TransactionBuild(e.to_string())`. That regresses the new wallet error contract for retryable depletion cases: a normal `BuilderError::CoinSelection(SelectionError::InsufficientFunds | NoUtxosAvailable)` now surfaces as a generic builder error instead of `NoSpendableInputs`. Callers of the two send paths therefore receive inconsistent error semantics for the same underlying failure and are forced back to string inspection on the DashPay path.
Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.
…per (CMT-001, CMT-004)
Replace brittle `e.to_string()` substring matches in `send_to_addresses`
and `send_payment` with a typed `classify_build_error` helper that
pattern-matches `BuilderError::InsufficientFunds` and
`BuilderError::CoinSelection(SelectionError::{NoUtxosAvailable,
InsufficientFunds})` into the retryable
`PlatformWalletError::NoSpendableInputs`. Every other build failure
falls through to fatal `PlatformWalletError::TransactionBuild` with the
upstream Display text preserved in `context`.
Unit-pinned with six assertions over the typed variants so a future
upstream rewording of the `Display` impl cannot silently downgrade
`NoSpendableInputs` back to `TransactionBuild`.
Drops the stale `TODO(CMT-005, #3585)` comment.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…pinned behavior (CMT-002) The previous docstring claimed "a wallet restart or full sync will reconcile" the leaked reservation, but no in-process reclaim API exists today — `leak_until_sync` calls `mem::forget(self)` and the outpoints stay pinned in `OutpointReservations` until the process exits. The unit test `leak_until_sync_keeps_reservation_held` pins exactly that behavior. Rewrite the doc to plainly state "held until process restart", keep the safer-of-two-bad-outcomes rationale, and add a follow-up TODO referencing @thepastaclaw's PR #3585 review for a future `OutpointReservations::reclaim_confirmed` API hooked into periodic sync. No behaviour change — docs only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…Wallet/IdentityWallet (CMT-003) Extract `broadcast_and_reconcile` — the broadcast → check_core_transaction → release-or-leak decision tree shared by `CoreWallet::send_to_addresses` and `IdentityWallet::send_payment`. The path-specific bits stay inlined at each call site (UTXO snapshot/filter, change-address peek-burn-commit loop, builder construction, post-broadcast reservation reconcile target account layout) — the helper covers only the genuinely shared invariant code where divergence would cause double-spend or leaked-reservation bugs. A `FnOnce(&mut PlatformWalletInfo, &Txid, bool /*reconciled*/)` hook runs inside the post-broadcast write lock so call-site-specific bookkeeping (e.g. `record_dashpay_payment`) sees the same locked critical section as the reconcile. Pin the helper's three invariants directly with new unit tests: * `broadcast_and_reconcile_drops_reservation_on_broadcast_failure` — the hook is NOT invoked and the reservation is released so the caller can retry. * `broadcast_and_reconcile_leaks_when_wallet_missing` — stale wallet handle leaks the reservation (no concurrent re-selection of an already-broadcast outpoint). * `send_to_addresses_success_invokes_hook_and_releases_reservation` — end-to-end happy path through the shared helper. The three pre-existing CMT-003 concurrency/rollback tests in `broadcast.rs` (`concurrent_same_utxo_sends_resolve_via_reservation_set`, `broadcast_failure_releases_reservation_for_retry`, `unreconciled_broadcast_keeps_reservation_held`) continue to exercise the same shared helper — no separate `send_payment` ports are needed because the helper IS the shared seam those tests target. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@thepastaclaw — addressing your 2026-05-25 review (the one posted as review body due to inline HTTP 422). Three commits on top of `b8043f9a`: CMT-001 + CMT-004 — typed coin-selection mapper (`8de859a6`) CMT-002 — `leak_until_sync` docstring corrected (`4d017b8a`) CMT-003 — shared broadcast/reconcile helper (`cd2ade5e`) Validations: `cargo fmt` clean, `clippy --tests -- -D warnings` clean, `cargo test --lib` 146 passed. 🤖 Co-authored by Claudius the Magnificent AI Agent |
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Two prior concerns remain on cd2ade5: unreconciled successful broadcasts still pin reservations until process restart, and send_payment still has no direct regression coverage for its DashPay-specific path after the helper refactor. The latest delta does fix the earlier typed-error-mapping and shared post-broadcast-tail issues, and I did not confirm the new claim that send_payment has a current reachable concurrent-spend bug from omitting the extra post-build recheck.
🟡 2 suggestion(s)
2 additional finding(s)
suggestion: `leak_until_sync` still leaves reservations stuck for the lifetime of the process
packages/rs-platform-wallet/src/wallet/core/reservations.rs (line 148)
This branch still calls mem::forget(self) with no matching in-process reclaim path. broadcast_and_reconcile reaches it after a successful broadcast whenever post-broadcast reconciliation fails or the wallet handle is missing, then returns Ok(txid) to the caller. At that point the reserved outpoints and pending change address remain pinned in OutpointReservations until the PlatformWalletManager is dropped, so later sends on the same wallet can keep failing with NoSpendableInputs or skip change indices even though the send already succeeded. The updated docstring and test now explicitly confirm that behavior instead of fixing it.
suggestion: `send_payment` still has no focused regression coverage for its own build, reconcile, and payment-recording path
packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 93)
There are still no tests that call IdentityWallet::send_payment on this head. The new coverage in wallet/core/broadcast.rs exercises broadcast_and_reconcile directly and through CoreWallet::send_to_addresses, but it does not cover the DashPay-specific work that remains inside send_payment: external-account lookup, recipient address derivation, change-address handling, and the on_reconcile hook that records the outgoing PaymentEntry. A regression in that path would still pass the current suite.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/reservations.rs`:148-167: `leak_until_sync` still leaves reservations stuck for the lifetime of the process
This branch still calls `mem::forget(self)` with no matching in-process reclaim path. `broadcast_and_reconcile` reaches it after a successful broadcast whenever post-broadcast reconciliation fails or the wallet handle is missing, then returns `Ok(txid)` to the caller. At that point the reserved outpoints and pending change address remain pinned in `OutpointReservations` until the `PlatformWalletManager` is dropped, so later sends on the same wallet can keep failing with `NoSpendableInputs` or skip change indices even though the send already succeeded. The updated docstring and test now explicitly confirm that behavior instead of fixing it.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:93-327: `send_payment` still has no focused regression coverage for its own build, reconcile, and payment-recording path
There are still no tests that call `IdentityWallet::send_payment` on this head. The new coverage in `wallet/core/broadcast.rs` exercises `broadcast_and_reconcile` directly and through `CoreWallet::send_to_addresses`, but it does not cover the DashPay-specific work that remains inside `send_payment`: external-account lookup, recipient address derivation, change-address handling, and the `on_reconcile` hook that records the outgoing `PaymentEntry`. A regression in that path would still pass the current suite.
Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.
|
One remaining non-blocking concern I still see in the exact head: When post-broadcast reconciliation fails, the code intentionally calls Why it matters: any false-negative Suggested follow-up: add a sync-driven reclaim path keyed by observed outpoints/txid, and cover the reconcile-failure branch with a regression test proving the wallet can recover without restart. The main targeted fixes look good to me: case-insensitive |
…-utxo-race-v3.1-dev # Conflicts: # packages/rs-platform-wallet-ffi/src/error.rs # packages/rs-platform-wallet/src/error.rs # packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift
…g_mut` in tests Post-merge interface drift: clippy 1.92's `manual_dangling_ptr` flags the existing test-helper pattern. Swap to the idiom clippy now expects — same non-null sentinel pointer, no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…electableInputs (14) Follow upstream #3651's umbrella intent: all three "can't-pick-UTXOs" wallet variants — `NoSpendableInputs` (incl. race-loser path), `OnlyOutputAddressesFunded`, `OnlyDustInputs` — now route to `ErrorNoSelectableInputs (14)`. The dedicated `ErrorNoSpendableInputs (30)` slot becomes dead and is removed from both the FFI enum and the Swift mirror (case, switch arm, `errorDescription` arm). The typed `PlatformWalletError::NoSpendableInputs` Display rendering (account_type / account_index / context + race-loser breadcrumb) still survives verbatim in the result message so callers can distinguish the underlying cause. `ErrorConcurrentSpendConflict (31)` is unchanged. Pinned FFI test updated to assert all three variants collapse onto code 14; added a separate pin for `ConcurrentSpendConflict (31)` keeping its dedicated code so future churn doesn't accidentally fold it into the umbrella too.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
The latest incremental push at 87d24e3 is limited to the FFI/Swift error-code remap and I did not find a new in-scope defect in that delta. Two prior wallet-core concerns from earlier commits in PR #3585 remain valid on this head and should still be carried forward: the post-broadcast leak path permanently pins reservations in-process, and the rewritten DashPay send_payment path still lacks focused regression coverage.
🟡 2 suggestion(s)
2 additional finding(s)
suggestion: Successful broadcasts can still leave UTXOs and change addresses reserved for the life of the process
packages/rs-platform-wallet/src/wallet/core/reservations.rs (line 148)
broadcast_and_reconcile() still sends every successful-but-unreconciled broadcast down the reservation.leak_until_sync() branch at packages/rs-platform-wallet/src/wallet/core/broadcast.rs:130-146, and leak_until_sync() still implements that by mem::forget(self) with an explicit note that there is no in-process reclaim path. That means this PR's shared send-flow can leave a transaction that was already accepted by the network permanently pinning its selected outpoints and pending change address inside OutpointReservations until the wallet process restarts. The branch avoids an immediate double-spend attempt, but the resulting stuck reservations can still make later sends fail with NoSpendableInputs or burn change indices indefinitely on a long-lived wallet handle.
suggestion: The rewritten `IdentityWallet::send_payment` path still has no direct regression test
packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 93)
IdentityWallet::send_payment now contains substantial PR-specific logic of its own: contact external-account lookup, recipient address derivation, funding-account change-address advancement, reservation/filtering against concurrent sends, the call into broadcast_and_reconcile, and the on_reconcile hook that records the outgoing PaymentEntry. A repository-wide search on this head finds no test that calls send_payment directly; current coverage only exercises the shared helper and CoreWallet::send_to_addresses in wallet/core/broadcast.rs. Because of that gap, a regression in the DashPay-specific build/reconcile/payment-recording path introduced by this PR can still pass the suite.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/reservations.rs`:148-172: Successful broadcasts can still leave UTXOs and change addresses reserved for the life of the process
`broadcast_and_reconcile()` still sends every successful-but-unreconciled broadcast down the `reservation.leak_until_sync()` branch at `packages/rs-platform-wallet/src/wallet/core/broadcast.rs:130-146`, and `leak_until_sync()` still implements that by `mem::forget(self)` with an explicit note that there is no in-process reclaim path. That means this PR's shared send-flow can leave a transaction that was already accepted by the network permanently pinning its selected outpoints and pending change address inside `OutpointReservations` until the wallet process restarts. The branch avoids an immediate double-spend attempt, but the resulting stuck reservations can still make later sends fail with `NoSpendableInputs` or burn change indices indefinitely on a long-lived wallet handle.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:93-327: The rewritten `IdentityWallet::send_payment` path still has no direct regression test
`IdentityWallet::send_payment` now contains substantial PR-specific logic of its own: contact external-account lookup, recipient address derivation, funding-account change-address advancement, reservation/filtering against concurrent sends, the call into `broadcast_and_reconcile`, and the `on_reconcile` hook that records the outgoing `PaymentEntry`. A repository-wide search on this head finds no test that calls `send_payment` directly; current coverage only exercises the shared helper and `CoreWallet::send_to_addresses` in `wallet/core/broadcast.rs`. Because of that gap, a regression in the DashPay-specific build/reconcile/payment-recording path introduced by this PR can still pass the suite.
Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.
… (Found-026) [backport] (#3658) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
The latest incremental patch at b9a3be72c7a0b5cc7c14e2338ca26d0335c90281 correctly fixes the receive-address handout race in PlatformAddressWallet and adds focused regression coverage for that path; I did not find a new defect in the two files changed by this last commit. In the cumulative PR range for #3585, however, both carried-forward prior findings remain valid: the post-broadcast unreconciled-success path still pins reservations for the rest of the process, and the rewritten DashPay send_payment path still has no direct regression test covering its call-site-specific behavior.
🟡 2 suggestion(s)
2 additional finding(s)
suggestion: Successful-but-unreconciled broadcasts still pin reservations until process restart
packages/rs-platform-wallet/src/wallet/core/reservations.rs (line 139)
ReservationGuard::leak_until_sync() still preserves reservations by skipping Drop with mem::forget(self), and the helper used by both send paths still takes that branch whenever broadcast succeeds but post-broadcast reconciliation does not (packages/rs-platform-wallet/src/wallet/core/broadcast.rs:130-146). That means send_to_addresses and send_payment can return success while leaving selected outpoints and pending change addresses reserved for the lifetime of the process if the wallet is missing or check_core_transaction(..) reports is_relevant = false. The double-spend risk is avoided, but the wallet can then degrade into persistent NoSpendableInputs or permanently burned change slots until restart because there is still no sync-time reclaim path.
suggestion: `IdentityWallet::send_payment` still lacks a direct regression test for its rewritten path
packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 93)
A repository search on this head still finds no test that calls IdentityWallet::send_payment directly. The added coverage in wallet/core/broadcast.rs validates the shared broadcast_and_reconcile() helper, but it does not execute the DashPay-specific logic that this PR moved into send_payment: external-account lookup, recipient-address derivation, funding-account change-address advancement, reservation filtering against concurrent sends, and the on_reconcile hook that records the outgoing PaymentEntry. Because those behaviors remain untested end-to-end, a regression in the send_payment call site can still pass the current suite even though the shared helper is covered.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/reservations.rs`:139-164: Successful-but-unreconciled broadcasts still pin reservations until process restart
`ReservationGuard::leak_until_sync()` still preserves reservations by skipping `Drop` with `mem::forget(self)`, and the helper used by both send paths still takes that branch whenever broadcast succeeds but post-broadcast reconciliation does not (`packages/rs-platform-wallet/src/wallet/core/broadcast.rs:130-146`). That means `send_to_addresses` and `send_payment` can return success while leaving selected outpoints and pending change addresses reserved for the lifetime of the process if the wallet is missing or `check_core_transaction(..)` reports `is_relevant = false`. The double-spend risk is avoided, but the wallet can then degrade into persistent `NoSpendableInputs` or permanently burned change slots until restart because there is still no sync-time reclaim path.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:93-316: `IdentityWallet::send_payment` still lacks a direct regression test for its rewritten path
A repository search on this head still finds no test that calls `IdentityWallet::send_payment` directly. The added coverage in `wallet/core/broadcast.rs` validates the shared `broadcast_and_reconcile()` helper, but it does not execute the DashPay-specific logic that this PR moved into `send_payment`: external-account lookup, recipient-address derivation, funding-account change-address advancement, reservation filtering against concurrent sends, and the `on_reconcile` hook that records the outgoing `PaymentEntry`. Because those behaviors remain untested end-to-end, a regression in the `send_payment` call site can still pass the current suite even though the shared helper is covered.
Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.
Summary
DPNS case-insensitive resolution + a wallet-wide concurrency hardening pass. The PR closes two distinct race classes that both stemmed from the same root cause — wallet state was being mutated speculatively before broadcast confirmation:
OutpointReservationsset consulted by bothCoreWallet::send_to_addressesandIdentityHandle::send_payment(DashPay), with RAII rollback on broadcast failure.next_unused_receive_address(Found-026). Two concurrent hand-outs returned the same(account, index)slot because the pool'shighest_usedwas advanced eagerly. Fixed by reserving the slot in an in-memoryAddressReservationsset; the upstreamAddressPoolis left untouched until a real payment confirms via chain sync..dashsuffix.origin/v3.1-dev(incl. feat(platform-wallet): IdentityManager::identity_ids + FFI no-selectable-inputs error mapping #3651, feat(platform-wallet): serde support #3637), collapsedNoSpendableInputsonto the upstream umbrellaErrorNoSelectableInputs (14), hardenedcore_wallet/broadcast.rsagainst Swift null-baseAddress empty buffers.#3658was merged into this branch; the address-race fix and the new genericReservations<K>primitive now live here.Fix 1 — Case-insensitive
.dashsuffix in DPNS resolutionSdk::resolve_dpns_namestripped the.dashsuffix with an exact-match.Alice.DASHfell into the else branch — the whole string was treated as a label, DPNS lookup missed.Empty-label rejection added: a bare
.dash(no label) returns an explicit error.Fix 2 — Concurrent same-UTXO race + atomic state across spend paths
Generic primitive
A new
Reservations<K: Eq + Hash + Clone + Debug>lives inpackages/rs-platform-wallet/src/wallet/core/reservations.rs. It composes:reserve()method returning aReservationGuard<K>(RAII).release_after_commit()— explicit handoff to durable state.leak_until_sync()— keeps the reservation past guard drop, until chain sync confirms.Two concrete specializations:
OutpointReservationsOutPointsend_to_addresses/send_paymentAddressReservations(u32, u32)—(account_index, address_index)2a — UTXO race across CoreWallet and IdentityHandle
Both
CoreWallet::send_to_addressesandIdentityHandle::send_paymentfollow the same orchestration:ManagedWalletInfo.OutpointReservations+ pending change-address set; filter spendable UTXOs against it.NoSpendableInputs(don't broadcast).check_core_transaction(.., Mempool, .., update_state=true)— transitions outpoints from "reserved" to "spent" and the change address from "pending" to "derived" atomically.IdentityHandleclones the sameOutpointReservationsfrom its siblingCoreWallet, so DashPay payments and core spends consult one reservation set.Two new typed errors on
PlatformWalletError:NoSpendableInputs { account_type, account_index, context }— race-loser short-circuit or genuinely-depleted wallet.ConcurrentSpendConflict { selected: Vec<OutPoint> }— builder-invariant tripwire; regression-pinned.2b — Receive-address hand-out race (Found-026, from merged #3658)
next_unused_receive_addresspreviously calledmark_index_usedon the pool, advancinghighest_usedeagerly. Two concurrent calls could return the same index before either address received a payment, and a single failed handout permanently inflated the gap-limit accounting.The fix:
(account_index, address_index)slot in an in-memoryAddressReservationsset withleak_until_sync()semantics.next_unused_with_infois scanned forward from index 0; the first slot that is neitherusednor reserved is returned.AddressPool.highest_usedis not advanced — chain sync flipsusedwhen a real payment lands, at which point the reservation becomes redundant.next_unused_receive_address_does_not_advance_highest_used.Concurrent-broadcast contract
When two callers race on the same wallet (whether two
send_to_addresses, two DashPay payments, or one of each):NoSpendableInputs.Adopted from dash-evo-tool's SPV-payment path (
backend_task/core/mod.rs). Validated byconcurrent_callers_get_no_spendable_inputsinbroadcast.rs.Fix 3 — FFI surface alignment with v3.1-dev
Umbrella error mapping
v3.1-dev (PR #3651) introduced
ErrorNoSelectableInputs (14)as the umbrella FFI code for "can't pick UTXOs" causes. After the merge, all three Rust variants now route to that code:OnlyOutputAddressesFunded { .. }ErrorNoSelectableInputs (14)OnlyDustInputs { .. }ErrorNoSelectableInputs (14)NoSpendableInputs { .. }ErrorNoSelectableInputs (14)ConcurrentSpendConflict { .. }ErrorConcurrentSpendConflict (31)Display payloads (including the dust-breadcrumb fields and
NoSpendableInputscontext) survive the boundary verbatim. Swift mirror updated.Null-pointer hardening in
core_wallet/broadcast.rsstd::slice::from_raw_partsrequires non-null forlen == 0. Swift's emptyArray.withUnsafeBufferPointer.baseAddressreturnsnil. Guarded.Files changed (net vs
v3.1-dev)packages/rs-sdk/src/platform/dpns_usernames/mod.rspackages/rs-platform-wallet/src/wallet/core/reservations.rsReservations<K>+OutpointReservations+AddressReservations+ guardspackages/rs-platform-wallet/src/wallet/core/broadcast.rspackages/rs-platform-wallet/src/wallet/identity/network/payments.rssend_paymentreservation orchestrationpackages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rsOutpointReservationscloned fromCoreWalletpackages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rsnext_unused_receive_addressusesAddressReservations(from #3658)packages/rs-platform-wallet/src/wallet/platform_wallet.rsIdentityHandleat constructionpackages/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+ConcurrentSpendConflictpackages/rs-platform-wallet-ffi/src/error.rsConcurrentSpendConflictpackages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rspackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swiftConcurrentSpendConflict)Test plan
cargo fmt --all— cleancargo clippy --workspace --all-targets -- -D warnings— cleancargo test -p dash-sdk --lib— green (DPNS case-insensitivity tests)cargo test -p platform-wallet --lib— green incl.concurrent_callers_get_no_spendable_inputs,next_unused_receive_address_does_not_advance_highest_usedcargo test -p platform-wallet-ffi --lib— green incl.no_selectable_inputs_maps_to_umbrella_code,concurrent_spend_conflict_keeps_dedicated_codeConcurrentSpendConflictreachable only via builder-invariant violation (regression tripwire only) (deferred — needs running node)next_unused_receive_addresscalls and verify they return distinct slots (deferred — needs running node)Provenance
StateTransitionFactory#810 and feat(wasm-dpp): provide external entropy generator to document factory #845. Supersedes fix: case-insensitive .dash suffix and UTXO double-spend prevention (backport from DET) #3466.🤖 Co-authored by Claudius the Magnificent AI Agent