Skip to content

Fix wallet lock state races - #5136

Merged
pwojcikdev merged 5 commits into
nanocurrency:developfrom
pwojcikdev:wallet-lock-race
Aug 15, 2026
Merged

Fix wallet lock state races#5136
pwojcikdev merged 5 commits into
nanocurrency:developfrom
pwojcikdev:wallet-lock-race

Conversation

@pwojcikdev

Copy link
Copy Markdown
Contributor

Problem

Investigating a TSAN report on #5127 surfaced three related races around the wallet lock state:

  • wallet::lock cleared the password fan without holding the store mutex. A clear landing in the middle of a rekey was overwritten by it, leaving the wallet unlocked even though lock() had completed.
  • The wallet store primitives that consume the wallet key (insert_adhoc, seed_set, seed, deterministic_key, deterministic_insert) re-unlocked the store internally and release_asserted on failure, while their callers gated on a separate valid_password check. A lock landing between the check and the assert aborted the node — reachable over RPC by running wallet_lock concurrently with account_create or wallet_change_seed.
  • A failed password attempt overwrites the live password and locks the wallet as a side effect, but enter_password refreshed the rep keys cache only on success, so the node kept voting with keys from a wallet that already reported itself locked.

None of these can corrupt the wallet database: entries are encrypted under the wallet key, which never changes — rekey only re-wraps it under the new password, and every write goes through a check-validated cipher.

Changes

  • Password clearing moved into wallet_store::password_clear, serialized under the store mutex against rekey and attempt_password: either the clear lands after the rekey and the wallet ends up locked, or before it, in which case the rekey fails cleanly
  • The store primitives now take a wallet_cipher, obtainable only through unlock(), so an unauthenticated call cannot be expressed; the release_asserts are gone. Entry points unlock once and thread the cipher through, move and import unlock both stores up front, and change_seed reports a locked wallet through its result instead of relying on the racy is_locked pre-checks in the RPC, CLI and Qt callers
  • enter_password refreshes the rep keys cache unconditionally so the cache never outlives the lock state it was built from
  • As a side effect, the Qt lock button now routes through wallet::lock, which also clears cached rep keys — previously locking from the GUI left the node voting with the cached key until the next periodic reps scan

Testing

  • wallet.insert_lock_race flips the lock state on one thread while hammering inserts on another; validated against a temporarily reintroduced check-then-act pattern, where it crashes on the first run
  • wallets.rep_keys_cache_failed_password_attempt fails without the unconditional refresh
  • rpc.wallet_concurrent_lock_create hammers account_create, wallet_lock, wallet_change_seed and password_enter in concurrent batches as a handler-level consistency check
  • Full wallet and RPC test suites pass with both LMDB and RocksDB backends; each commit builds and tests independently

🤖 Generated with Claude Code

wallet::lock cleared the password fan without holding the store mutex,
so a clear landing in the middle of a rekey was overwritten by it and
the wallet remained unlocked even though lock() had completed. The
clear now happens in wallet_store under the same mutex as rekey and
attempt_password: either it lands after the rekey and the wallet ends
up locked, or before it, in which case the rekey fails cleanly.
The wallet store primitives that consume the wallet key re-unlocked the
store internally and asserted on failure, while their callers gated on a
separate valid_password check. A lock landing between the check and the
assert aborted the node; wallet_lock concurrent with account_create or
wallet_change_seed made this reachable over RPC.

The primitives now take a wallet_cipher, obtainable only through
unlock(), so an unauthenticated call cannot be expressed. Entry points
unlock once and thread the cipher through, move and import unlock both
stores up front, and change_seed reports a locked wallet through its
result instead of relying on the racy is_locked pre-checks in the RPC,
CLI and Qt callers.

The insert_lock_race test flips the lock state while hammering inserts;
against the old check-then-act pattern it crashes on the first run.
A failed password attempt overwrites the live password and locks the
wallet as a side effect, but enter_password refreshed the rep keys
cache only on success. The node kept voting with keys from a wallet
that already reported itself locked, until the periodic reps scan
rebuilt the cache. Refresh unconditionally so the cache never outlives
the lock state it was built from.
@gr0vity-dev-bot

gr0vity-dev-bot commented Aug 12, 2026

Copy link
Copy Markdown

Test Results for Commit e0bee69

Pull Request 5136: Results
Overall Status:

Test Case Results

  • 5n4pr_conf_10k_bintree: PASS (Duration: 121s)
  • 5n4pr_conf_10k_change: PASS (Duration: 149s)
  • 5n4pr_conf_change_dependant: PASS (Duration: 119s)
  • 5n4pr_conf_change_independant: PASS (Duration: 191s)
  • 5n4pr_conf_send_dependant: PASS (Duration: 116s)
  • 5n4pr_conf_send_independant: PASS (Duration: 122s)
  • 5n4pr_rocks_10k_bintree: PASS (Duration: 129s)
  • 5n4pr_rocks_10k_change: PASS (Duration: 186s)

Last updated: 2026-08-14 21:06:28 UTC

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes wallet lock-state races by making key operations use validated ciphers and synchronizing password clearing.

Changes:

  • Serializes locking against password and rekey operations.
  • Propagates locked-wallet errors through RPC, CLI, and Qt.
  • Adds wallet lock and representative-cache concurrency tests.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
nano/node/wallet.hpp Defines cipher-based wallet APIs.
nano/node/wallet.cpp Implements synchronized locking and cipher propagation.
nano/node/json_handler.cpp Handles locked seed changes.
nano/node/cli.cpp Reports locked seed-change failures.
nano/qt/qt.cpp Handles locked seed imports.
nano/core_test/wallet.cpp Updates APIs and adds lock-race coverage.
nano/core_test/wallets.cpp Tests representative-cache invalidation.
nano/rpc_test/wallet_rpc.cpp Adds concurrent wallet RPC coverage.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread nano/node/wallet.hpp
Comment on lines +4794 to +4797
if (auto account_result = wallet->change_seed (seed, static_cast<uint32_t> (count)))
{
nano::public_key account (wallet->change_seed (seed, static_cast<uint32_t> (count)));
rpc_l->response_l.put ("success", "");
rpc_l->response_l.put ("last_restored_account", account.to_account ());
rpc_l->response_l.put ("last_restored_account", account_result.value ().to_account ());

@pwojcikdev pwojcikdev Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in e1ec94a: change_seed now returns a change_seed_result with the last restored account and the count, both read within its own write transaction; both RPC handlers populate the response from that single result.

🤖 Generated with Claude Code

@pwojcikdev pwojcikdev Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correction: the atomic change_seed result was dropped from this PR and will be revisited separately — the two-transaction read of restored_count remains for now. (It predates this PR.)

🤖 Generated with Claude Code

Comment thread nano/node/wallet.cpp
Comment thread nano/rpc_test/wallet_rpc.cpp
A cipher proves that some wallet was unlocked, but nothing tied it to
the store that issued it: passing wallet B's cipher to wallet A's
seed_set compiled and produced ciphertext A cannot decrypt. The cipher
now records its issuer and every consuming primitive asserts on it in
all builds, so cross-wallet use cannot write ciphertext, including a
swapped cipher pair inside move or import.
lock_rekey_race flips the lock while another thread rekeys, asserting
the wallet always stays locked once lock() returns. The lock samples
the rekey cycle with a varying delay, since the transaction handoff
otherwise phase-locks the two threads ahead of the race window. With
the password clear made unsynchronized again the test fails within
milliseconds once the rekey window is at production KDF scale.

The concurrent lock test now accepts only the wallet locked error for
failed key operations, so a torn state surfacing as a different error
no longer passes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

@pwojcikdev
pwojcikdev merged commit 19a17b2 into nanocurrency:develop Aug 15, 2026
39 of 40 checks passed
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.

3 participants