-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(wallet): keep mixed CoinJoin inputs locked until the finalized tx is observed #7480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
51ecf82
bced3b7
f9c4beb
490f54c
22608a9
71c8dde
6695726
f13e0e0
82794fe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,8 +14,10 @@ | |
|
|
||
| #include <chain.h> | ||
| #include <chainparams.h> | ||
| #include <coins.h> | ||
| #include <core_io.h> | ||
| #include <net.h> | ||
| #include <txmempool.h> | ||
| #include <netmessagemaker.h> | ||
| #include <shutdown.h> | ||
| #include <util/check.h> | ||
|
|
@@ -28,6 +30,7 @@ | |
| #include <wallet/coinselection.h> | ||
| #include <wallet/receive.h> | ||
| #include <wallet/spend.h> | ||
| #include <wallet/walletdb.h> | ||
|
|
||
| #include <memory> | ||
| #include <ranges> | ||
|
|
@@ -577,6 +580,33 @@ void CCoinJoinClientSession::CompletedTransaction(PoolMessage nMessageID) | |
| if (nMessageID == MSG_SUCCESS) { | ||
| m_clientman.UpdatedSuccessBlock(); | ||
| keyHolderStorage.KeepAll(); | ||
| // Our inputs are now committed to the finalized mixing transaction but the | ||
| // DSCOMPLETE message may arrive well before the (trickle-relayed) DSTX does. | ||
| // Do not release those inputs yet: keep them locked until the wallet actually | ||
| // observes a transaction spending them, otherwise another session could pick | ||
| // them up in the meantime (esp. with -coinjoinmultisession) and double-spend | ||
| // them. Anything else we locked (e.g. collateral inputs which may never be | ||
| // spent) is unlocked immediately as before. | ||
| std::set<COutPoint> setMixingInputs; | ||
| { | ||
| LOCK(cs_coinjoin); | ||
| for (const auto& entry : vecEntries) { | ||
| for (const auto& txdsin : entry.vecTxDSIn) { | ||
| setMixingInputs.emplace(txdsin.prevout); | ||
| } | ||
| } | ||
| } | ||
| std::vector<COutPoint> vecPending; | ||
| std::vector<COutPoint> vecUnlockNow; | ||
| for (const auto& outpoint : vecOutPointLocked) { | ||
| if (setMixingInputs.count(outpoint)) { | ||
| vecPending.push_back(outpoint); | ||
| } else { | ||
| vecUnlockNow.push_back(outpoint); | ||
| } | ||
| } | ||
| vecOutPointLocked = std::move(vecUnlockNow); | ||
| m_clientman.AddPendingObservation(vecPending); | ||
| WalletCJLogPrint(m_wallet, "CompletedTransaction -- success\n"); | ||
| } else { | ||
| WITH_LOCK(m_wallet->cs_wallet, keyHolderStorage.ReturnAll()); | ||
|
|
@@ -592,6 +622,135 @@ void CCoinJoinClientManager::UpdatedSuccessBlock() | |
| nCachedLastSuccessBlock = nCachedBlockHeight; | ||
| } | ||
|
|
||
| void CCoinJoinClientManager::AddPendingObservation(const std::vector<COutPoint>& outpoints) | ||
| { | ||
| AssertLockNotHeld(cs_pending_obs); | ||
| if (outpoints.empty()) return; | ||
|
|
||
| LOCK(m_wallet->cs_wallet); | ||
| LOCK(cs_pending_obs); | ||
| wallet::WalletBatch batch(m_wallet->GetDatabase()); | ||
| LoadPendingObservations(batch); | ||
|
|
||
| const int64_t nNow{GetTime()}; | ||
| bool fPersisted{true}; | ||
| for (const auto& outpoint : outpoints) { | ||
| // Persist the lock so that a restart before the finalized transaction is | ||
| // observed cannot make the input available for selection again | ||
| fPersisted &= m_wallet->LockCoin(outpoint, &batch); | ||
| m_pending_obs.emplace(outpoint, nNow); | ||
| WalletCJLogPrint(m_wallet, "CCoinJoinClientManager::%s -- %s is locked until the finalized mixing transaction is observed\n", | ||
| __func__, outpoint.ToStringShort()); | ||
| } | ||
| fPersisted &= batch.WriteCoinJoinPendingObs(m_pending_obs); | ||
| if (!fPersisted) { | ||
| // The in-memory lock still protects these inputs for as long as this process | ||
| // runs, but a restart would make them selectable again while a valid mixing | ||
| // transaction spending them may already be in flight. Nothing we can do about | ||
| // it here beyond making the failure loud - the wallet database is broken. | ||
| LogPrintf("CCoinJoinClientManager::%s -- ERROR: failed to persist locks for %d successfully mixed input(s), " | ||
| "they will not survive a restart\n", | ||
| __func__, outpoints.size()); | ||
| } | ||
| } | ||
|
|
||
| void CCoinJoinClientManager::LoadPendingObservations(wallet::WalletBatch& batch) | ||
| { | ||
| AssertLockHeld(cs_pending_obs); | ||
|
|
||
| if (m_pending_obs_loaded) return; | ||
| m_pending_obs_loaded = true; | ||
| // Missing record simply means nothing was pending when we last shut down | ||
| if (!batch.ReadCoinJoinPendingObs(m_pending_obs)) return; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a wallet already has pending observations and this first lazy database read fails—due to a transient I/O error or an unreadable Useful? React with 👍 / 👎. |
||
| WalletCJLogPrint(m_wallet, "CCoinJoinClientManager::%s -- loaded %d pending observation(s)\n", __func__, | ||
| m_pending_obs.size()); | ||
| } | ||
|
|
||
| void CCoinJoinClientManager::CheckPendingObservations(const CTxMemPool& mempool) | ||
| { | ||
| AssertLockNotHeld(cs_pending_obs); | ||
|
|
||
| // nothing to do in the common case, don't touch the wallet at all | ||
| if (WITH_LOCK(cs_pending_obs, return m_pending_obs_loaded && m_pending_obs.empty())) return; | ||
|
|
||
| LOCK(m_wallet->cs_wallet); | ||
| LOCK(cs_pending_obs); | ||
|
|
||
| wallet::WalletBatch batch(m_wallet->GetDatabase()); | ||
| LoadPendingObservations(batch); | ||
|
|
||
| const int64_t nNow{GetTime()}; | ||
| const bool fSynced{m_mn_sync.IsBlockchainSynced()}; | ||
| bool fChanged{false}; | ||
| for (auto it = m_pending_obs.begin(); it != m_pending_obs.end();) { | ||
| const COutPoint& outpoint = it->first; | ||
| if (!m_wallet->IsLockedCoin(outpoint)) { | ||
| // The user released the lock manually (e.g. via lockunspent), respect that | ||
| it = m_pending_obs.erase(it); | ||
| fChanged = true; | ||
| continue; | ||
| } | ||
| if (m_wallet->IsSpent(outpoint)) { | ||
| // The wallet observed a transaction spending this input, it is no longer | ||
| // selectable anyway, drop the protective lock | ||
| WalletCJLogPrint(m_wallet, "CCoinJoinClientManager::%s -- observed spend of %s, releasing lock\n", __func__, | ||
| outpoint.ToStringShort()); | ||
| m_wallet->UnlockCoin(outpoint, &batch); | ||
| it = m_pending_obs.erase(it); | ||
|
Comment on lines
+762
to
+763
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the wallet database returns an I/O error while erasing Useful? React with 👍 / 👎. |
||
| fChanged = true; | ||
| continue; | ||
| } | ||
| // Only ever consult chain/mempool spentness on a synced chain: while catching up | ||
| // the spending transaction may sit in a block we have not downloaded yet, and | ||
| // releasing the input on the strength of that would defeat the whole point | ||
| if (fSynced && nNow - it->second >= PENDING_OBSERVATION_TIMEOUT_SECONDS) { | ||
| // NOTE: findCoins() reports outputs which exist in the chain UTXO set or are | ||
| // created by a mempool transaction; it does NOT know whether a mempool | ||
| // transaction spends them (CCoinsViewMemPool::GetCoin never looks at | ||
| // mapNextTx). The finalized mixing transaction sitting unprocessed in our own | ||
| // mempool is exactly the case we must not misread as "never propagated", so | ||
| // ask the mempool about spentness explicitly as well. | ||
| std::map<COutPoint, Coin> coins{{outpoint, Coin{}}}; | ||
| m_wallet->chain().findCoins(coins); | ||
| if (!coins.at(outpoint).IsSpent() && !mempool.isSpent(outpoint)) { | ||
|
Comment on lines
+777
to
+779
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: Acquire chain locks before the wallet lock
source: ['codex']
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lock ordering (cs_wallet → cs_main) — FALSE POSITIVE wallet/spend.cpp:1170 — FundTransaction takes LOCK(wallet.cs_wallet) eight lines before wallet.chain().findCoins(coins). That's upstream Bitcoin code. No wallet function asserts or requires cs_main (grep AssertLockHeld(cs_main) / EXCLUSIVE_LOCKS_REQUIRED(cs_main across src/wallet/ returns nothing). The suggested remedy would also introduce the bug it warns about: releasing cs_wallet mid-loop while still holding cs_pending_obs establishes cs_pending_obs → cs_wallet, inverting against AddPendingObservation, which takes them the other way. Plus a TOCTOU gap between the spentness query and the unlock decision. No change made. The doc reference the finding leans on (developer-notes.md:598) is a generic illustration of what deadlock is — it doesn't declare a required order between these two. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Resolved in Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
Comment on lines
+778
to
+779
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the timeout check races with the finalized transaction being mined, AGENTS.md reference: AGENTS.md:L169-L171 Useful? React with 👍 / 👎.
Comment on lines
+777
to
+779
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: Take an atomic chain-and-mempool spentness snapshot
source: ['codex'] There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a verified InstantSend lock reaches this node before its transaction and the transaction remains unavailable past the one-hour timeout, AGENTS.md reference: AGENTS.md:L160-L161 Useful? React with 👍 / 👎. |
||
| // Still unspent in chain and mempool long after the session completed - | ||
| // the finalized transaction most likely never propagated, release the | ||
| // input so the wallet does not lose it forever | ||
| LogPrintf("CCoinJoinClientManager::%s -- WARNING: never observed finalized mixing transaction for %s, " | ||
| "releasing lock after %d seconds\n", | ||
| __func__, outpoint.ToStringShort(), PENDING_OBSERVATION_TIMEOUT_SECONDS); | ||
| m_wallet->UnlockCoin(outpoint, &batch); | ||
| it = m_pending_obs.erase(it); | ||
| fChanged = true; | ||
| continue; | ||
| } | ||
| // Spent according to chain/mempool but the wallet has not recorded the | ||
| // spending transaction yet, keep waiting for it | ||
| it->second = nNow; | ||
| fChanged = true; | ||
| } | ||
| ++it; | ||
| } | ||
| if (fChanged && !batch.WriteCoinJoinPendingObs(m_pending_obs)) { | ||
| LogPrintf("CCoinJoinClientManager::%s -- ERROR: failed to persist %d pending observation(s)\n", __func__, | ||
| m_pending_obs.size()); | ||
| } | ||
| } | ||
|
|
||
| bool CCoinJoinClientManager::IsPendingObservation(const COutPoint& outpoint) const | ||
| { | ||
| AssertLockNotHeld(cs_pending_obs); | ||
| LOCK(cs_pending_obs); | ||
| return m_pending_obs.count(outpoint) > 0; | ||
| } | ||
|
|
||
| size_t CCoinJoinClientManager::GetPendingObservationCount() const | ||
| { | ||
| AssertLockNotHeld(cs_pending_obs); | ||
| LOCK(cs_pending_obs); | ||
| return m_pending_obs.size(); | ||
| } | ||
|
|
||
| bool CCoinJoinClientManager::WaitForAnotherBlock() const | ||
| { | ||
| if (!m_mn_sync.IsBlockchainSynced()) return true; | ||
|
|
@@ -1714,9 +1873,16 @@ void CCoinJoinClientManager::UpdatedBlockTip(const CBlockIndex* pindex) | |
|
|
||
| void CCoinJoinClientManager::DoMaintenance(ChainstateManager& chainman, CConnman& connman, const CTxMemPool& mempool) | ||
| { | ||
| if (ShutdownRequested()) return; | ||
|
|
||
| // Run this before the mixing gates below: inputs of already completed sessions | ||
| // must be able to unlock even when mixing or CoinJoin itself is disabled, | ||
| // otherwise their persisted locks could linger forever | ||
| CheckPendingObservations(mempool); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a wallet with pending observations is restarted with AGENTS.md reference: AGENTS.md:L169-L171 Useful? React with 👍 / 👎. |
||
|
|
||
| if (!CCoinJoinClientOptions::IsEnabled()) return; | ||
|
|
||
| if (!m_mn_sync.IsBlockchainSynced() || ShutdownRequested()) return; | ||
| if (!m_mn_sync.IsBlockchainSynced()) return; | ||
|
|
||
| static int nTick = 0; | ||
| static int nDoAutoNextRun = nTick + COINJOIN_AUTO_TIMEOUT_MIN; | ||
|
|
@@ -1751,6 +1917,7 @@ UniValue CCoinJoinClientManager::getJsonInfo() const | |
| { | ||
| UniValue obj(UniValue::VOBJ); | ||
| obj.pushKV("running", isMixing()); | ||
| obj.pushKV("pending_inputs", static_cast<int64_t>(GetPendingObservationCount())); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After loading a wallet that has persisted pending observations, AGENTS.md reference: AGENTS.md:L169-L171 Useful? React with 👍 / 👎. |
||
|
|
||
| UniValue arrSessions(UniValue::VARR); | ||
| AssertLockNotHeld(cs_deqsessions); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a user makes an already session-locked input persistent with
lockunspent false ... true, the RPC explicitly allows the operation and writes alockedutxorecord, but this call cannot distinguish that user-owned record and adopts the outpoint as a CoinJoin pending lock. If the finalized transaction never propagates, the timeout path later callsUnlockCoinand erases the user's persistent lock, making the coin selectable contrary to the user's request. Track whether persistence already existed before adopting the input, and add a timeout test for that case.AGENTS.md reference: AGENTS.md:L169-L171
Useful? React with 👍 / 👎.