Skip to content

Comments

fix(wallet): commit DB write before updating in-memory lock state#7158

Closed
UdjinM6 wants to merge 2 commits intodashpay:developfrom
UdjinM6:fix/wallet-lock-atomicity
Closed

fix(wallet): commit DB write before updating in-memory lock state#7158
UdjinM6 wants to merge 2 commits intodashpay:developfrom
UdjinM6:fix/wallet-lock-atomicity

Conversation

@UdjinM6
Copy link

@UdjinM6 UdjinM6 commented Feb 19, 2026

Issue being fixed or feature implemented

LockCoin and UnlockCoin previously mutated setLockedCoins before writing to the DB.UnlockAllCoins cleared setLockedCoins regardless of success status. A failed batch write left in-memory and on-disk state diverged until wallet restart.

Discovered in #7155 (comment) by @coderabbitai

What was done?

Fix by writing to the DB first and only updating setLockedCoins (and calling RecalculateMixedCredit) on success. Also batch RecalculateMixedCredit by affected txid at the end of UnlockAllCoins.

How Has This Been Tested?

Breaking Changes

n/a

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation
  • I have assigned this pull request to a milestone

@github-actions
Copy link

github-actions bot commented Feb 19, 2026

✅ No Merge Conflicts Detected

This PR currently has no conflicts with other open PRs.

@coderabbitai
Copy link

coderabbitai bot commented Feb 19, 2026

Walkthrough

Locking/unlocking UTXOs in CWallet was reworked to persist database changes before updating in-memory state and to only remove in-memory locks after successful batch operations. LockCoin now attempts a WalletBatch write for the UTXO before inserting into the in-memory locked set and returns false if the batch write fails. UnlockCoin now returns true immediately if the coin was not locked; if it was locked, it performs a WalletBatch erase and only removes the in-memory lock and recalculates mixed credit after a successful batch erase, returning false on batch failure. UnlockAllCoins now attempts batch erases per locked coin, removes in-memory entries only for successful erases, and then recomputes mixed credit for affected transactions.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant CWallet
    participant WalletBatch
    participant InMemorySet
    participant Recalc

    Caller->>CWallet: LockCoin(coin, batch?)
    CWallet->>WalletBatch: WriteLockedUTXO(coin)
    alt WalletBatch write succeeds
        WalletBatch-->>CWallet: OK
        CWallet->>InMemorySet: InsertLocked(coin)
        CWallet-->>Caller: true
    else WalletBatch write fails
        WalletBatch-->>CWallet: Error
        CWallet-->>Caller: false
    end
Loading
sequenceDiagram
    participant Caller
    participant CWallet
    participant InMemorySet
    participant WalletBatch
    participant Recalc

    Caller->>CWallet: UnlockCoin(coin, batch?)
    CWallet->>InMemorySet: contains(coin)?
    alt coin was locked
        CWallet->>WalletBatch: EraseLockedUTXO(coin)
        alt WalletBatch erase succeeds
            WalletBatch-->>CWallet: OK
            CWallet->>InMemorySet: EraseLocked(coin)
            CWallet->>Recalc: RecalculateMixedCredit(txid)
            CWallet-->>Caller: true
        else WalletBatch erase fails
            WalletBatch-->>CWallet: Error
            CWallet-->>Caller: false
        end
    else coin not locked
        CWallet-->>Caller: true
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: ensuring DB writes complete before updating in-memory lock state, which is the core issue being fixed.
Description check ✅ Passed The pull request description clearly explains the issue being fixed (in-memory and on-disk state divergence due to premature state mutation), the solution implemented (DB-first approach with conditional updates), and specific changes made to three methods.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/wallet/wallet.cpp (1)

2728-2738: UnlockAllCoins has the same class of atomicity gap this PR fixes elsewhere.

setLockedCoins.clear() is called unconditionally regardless of whether the DB batch writes succeed. If any EraseLockedUTXO call returns false (or the batch fails to commit on destruction), in-memory state is cleared while the DB retains the locked entries — the reverse divergence from what this PR corrects. This is a pre-existing issue, but worth a follow-up to apply the same "write first, update in-memory only on success" pattern:

💡 Suggested follow-up fix
 bool CWallet::UnlockAllCoins()
 {
     AssertLockHeld(cs_wallet);
     bool success = true;
     WalletBatch batch(GetDatabase());
     for (auto it = setLockedCoins.begin(); it != setLockedCoins.end(); ++it) {
         success &= batch.EraseLockedUTXO(*it);
     }
-    setLockedCoins.clear();
+    if (success) {
+        setLockedCoins.clear();
+    }
     return success;
 }

Note: even with this change, batch commit failure on WalletBatch destruction isn't surfaced. A deeper fix would require a transactional commit check, but this at least prevents clearing in-memory state when individual erases are known to have failed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wallet/wallet.cpp` around lines 2728 - 2738, UnlockAllCoins currently
clears in-memory setLockedCoins unconditionally even if
WalletBatch::EraseLockedUTXO fails or the batch fails to persist; change
CWallet::UnlockAllCoins so it first attempts all EraseLockedUTXO calls (using
WalletBatch) and only clear setLockedCoins if every EraseLockedUTXO returned
true (i.e., track a local success flag while iterating over setLockedCoins and
call setLockedCoins.clear() only when success is true), leaving the in-memory
state intact on failure; reference CWallet::UnlockAllCoins, WalletBatch,
EraseLockedUTXO, and setLockedCoins when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/wallet/wallet.cpp`:
- Around line 2728-2738: UnlockAllCoins currently clears in-memory
setLockedCoins unconditionally even if WalletBatch::EraseLockedUTXO fails or the
batch fails to persist; change CWallet::UnlockAllCoins so it first attempts all
EraseLockedUTXO calls (using WalletBatch) and only clear setLockedCoins if every
EraseLockedUTXO returned true (i.e., track a local success flag while iterating
over setLockedCoins and call setLockedCoins.clear() only when success is true),
leaving the in-memory state intact on failure; reference
CWallet::UnlockAllCoins, WalletBatch, EraseLockedUTXO, and setLockedCoins when
making the change.

UdjinM6 and others added 2 commits February 20, 2026 02:00
LockCoin and UnlockCoin previously mutated setLockedCoins before
writing to the DB. A failed batch write left in-memory and on-disk
state diverged until wallet restart.

Fix by writing to the DB first and only updating setLockedCoins (and
calling RecalculateMixedCredit) on success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Only remove a coin from setLockedCoins after its DB entry is
successfully erased. On failure, leave that coin locked and continue
with the rest. Batch RecalculateMixedCredit by affected txid at the
end instead of per-item.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@UdjinM6 UdjinM6 force-pushed the fix/wallet-lock-atomicity branch from e0a6815 to f956291 Compare February 20, 2026 09:23
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/wallet/wallet.cpp (1)

2716-2726: RecalculateMixedCredit (and the no-op erase) called unconditionally even when was_locked == false.

The was_locked variable is introduced but only used to gate the DB erase. Lines 2723-2724 execute regardless:

bool was_locked = setLockedCoins.contains(output);
if (batch && was_locked && !batch->EraseLockedUTXO(output)) {
    return false;
}
setLockedCoins.erase(output);          // no-op when !was_locked
RecalculateMixedCredit(output.hash);   // unnecessary when !was_locked
return true;

setLockedCoins.erase on a non-member is harmless, but RecalculateMixedCredit does non-trivial work (cache invalidation / mixing credit recompute) for a tx whose lock state hasn't changed. Since was_locked is already computed, a guard is straightforward:

♻️ Proposed optional refactor
 bool CWallet::UnlockCoin(const COutPoint& output, WalletBatch* batch)
 {
     AssertLockHeld(cs_wallet);
     bool was_locked = setLockedCoins.contains(output);
     if (batch && was_locked && !batch->EraseLockedUTXO(output)) {
         return false;
     }
-    setLockedCoins.erase(output);
-    RecalculateMixedCredit(output.hash);
+    if (was_locked) {
+        setLockedCoins.erase(output);
+        RecalculateMixedCredit(output.hash);
+    }
     return true;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wallet/wallet.cpp` around lines 2716 - 2726, CWallet::UnlockCoin
currently computes was_locked but still calls setLockedCoins.erase(output) and
RecalculateMixedCredit(output.hash) unconditionally; change the logic to only
attempt batch->EraseLockedUTXO, call setLockedCoins.erase(output) and invoke
RecalculateMixedCredit(output.hash) when was_locked is true (i.e., the lock
state actually changed), preserving the existing early-return if batch &&
was_locked && !batch->EraseLockedUTXO(output) fails; references:
CWallet::UnlockCoin, setLockedCoins, WalletBatch::EraseLockedUTXO,
RecalculateMixedCredit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/wallet/wallet.cpp`:
- Around line 2716-2726: CWallet::UnlockCoin currently computes was_locked but
still calls setLockedCoins.erase(output) and RecalculateMixedCredit(output.hash)
unconditionally; change the logic to only attempt batch->EraseLockedUTXO, call
setLockedCoins.erase(output) and invoke RecalculateMixedCredit(output.hash) when
was_locked is true (i.e., the lock state actually changed), preserving the
existing early-return if batch && was_locked && !batch->EraseLockedUTXO(output)
fails; references: CWallet::UnlockCoin, setLockedCoins,
WalletBatch::EraseLockedUTXO, RecalculateMixedCredit.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/wallet/wallet.cpp (1)

2705-2714: Consider adding an early-return guard for already-locked coins (symmetry with UnlockCoin)

LockCoin now correctly writes to the DB before updating setLockedCoins. However, unlike UnlockCoin, there is no guard for the case where output is already in setLockedCoins. This results in a redundant WriteLockedUTXO DB write and an unnecessary RecalculateMixedCredit call — most visible during AutoLockMasternodeCollaterals() on startup, where masternodes collaterals are re-locked on an already-populated set.

♻️ Suggested symmetrical guard
 bool CWallet::LockCoin(const COutPoint& output, WalletBatch* batch)
 {
     AssertLockHeld(cs_wallet);
+    if (setLockedCoins.contains(output)) return true;
     if (batch && !batch->WriteLockedUTXO(output)) {
         return false;
     }
     setLockedCoins.insert(output);
     RecalculateMixedCredit(output.hash);
     return true;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wallet/wallet.cpp` around lines 2705 - 2714, LockCoin should
short-circuit when the output is already locked to avoid redundant DB writes and
RecalculateMixedCredit calls; modify CWallet::LockCoin to check setLockedCoins
(e.g., setLockedCoins.count(output) or contains) at the top and return true
immediately if already present, before calling batch->WriteLockedUTXO or
RecalculateMixedCredit, preserving the existing behavior when the coin is not
yet locked.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/wallet/wallet.cpp`:
- Around line 2705-2714: LockCoin should short-circuit when the output is
already locked to avoid redundant DB writes and RecalculateMixedCredit calls;
modify CWallet::LockCoin to check setLockedCoins (e.g.,
setLockedCoins.count(output) or contains) at the top and return true immediately
if already present, before calling batch->WriteLockedUTXO or
RecalculateMixedCredit, preserving the existing behavior when the coin is not
yet locked.

@UdjinM6 UdjinM6 force-pushed the fix/wallet-lock-atomicity branch from 98a30a0 to f956291 Compare February 20, 2026 09:59
@UdjinM6
Copy link
Author

UdjinM6 commented Feb 20, 2026

2705-2714: Consider adding an early-return guard for already-locked coins (symmetry with UnlockCoin)

LockCoin now correctly writes to the DB before updating setLockedCoins. However, unlike UnlockCoin, there is no guard for the case where output is already in setLockedCoins. This results in a redundant WriteLockedUTXO DB write and an unnecessary RecalculateMixedCredit call — most visible during AutoLockMasternodeCollaterals() on startup, where masternodes collaterals are re-locked on an already-populated set.

Can't actually do early returns, dropped 98a30a0.

LockCoin:
The lockunspent RPC test (wallet_basic.py lines 154-159) does a non-persistent lock followed by a persistent re-lock of the same coin. An early return would skip the WriteLockedUTXO, so after restart the lock would be gone. The insert on an already-present element is a no-op, and RecalculateMixedCredit is cheap for a single call. Not worth the correctness risk.

UnlockCoin:
The early return skips batch->EraseLockedUTXO when the in-memory set is out of sync with DB. But this would actually happen in the original code too... weird.

@UdjinM6 UdjinM6 marked this pull request as draft February 20, 2026 10:24
@UdjinM6 UdjinM6 removed this from the 23.1.1 milestone Feb 20, 2026
@UdjinM6 UdjinM6 closed this Feb 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant