-
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 5 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 |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| Updated RPCs | ||
| ------------ | ||
|
|
||
| * `getcoinjoininfo` gained a `pending_inputs` field, reporting how many | ||
| successfully mixed inputs are currently kept locked while waiting for the | ||
| finalized mixing transaction spending them to be observed. | ||
|
|
||
| CoinJoin | ||
| -------- | ||
|
|
||
| * After a mixing session completes successfully, the inputs a client | ||
| contributed to it are no longer released as soon as the `DSCOMPLETE` message | ||
| is processed. They stay locked until the wallet observes a transaction | ||
| spending them, or for at most an hour if that transaction never propagates. | ||
| Previously the completion message routinely overtook the trickle-relayed | ||
| mixing transaction, leaving a window in which another session (with | ||
| `-coinjoinmultisession=1`, where the one-block cooldown does not apply) could | ||
| select and sign the very same inputs and produce a second, conflicting | ||
| CoinJoin transaction. | ||
|
|
||
| These locks are persisted, so they survive a restart. They are tracked in a | ||
| new wallet database record, `cj_pending_obs`, which is written only when a | ||
| mixing session completes successfully. Older clients loading such a wallet | ||
| ignore the record, counting it as an unknown record; the accompanying | ||
| `lockedutxo` entries are understood by them regardless, so the inputs stay | ||
| locked either way. Locks the user set themselves via `lockunspent` are never | ||
| adopted or released by this mechanism, and unlocking a pending input manually | ||
| purges it from the pending set. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,10 +14,12 @@ | |
|
|
||
| #include <chain.h> | ||
| #include <chainparams.h> | ||
| #include <coins.h> | ||
| #include <core_io.h> | ||
| #include <net.h> | ||
| #include <netmessagemaker.h> | ||
| #include <shutdown.h> | ||
| #include <txmempool.h> | ||
| #include <util/check.h> | ||
| #include <util/fs_helpers.h> | ||
| #include <util/moneystr.h> | ||
|
|
@@ -28,8 +30,10 @@ | |
| #include <wallet/coinselection.h> | ||
| #include <wallet/receive.h> | ||
| #include <wallet/spend.h> | ||
| #include <wallet/walletdb.h> | ||
|
|
||
| #include <memory> | ||
| #include <optional> | ||
| #include <ranges> | ||
|
|
||
| #include <univalue.h> | ||
|
|
@@ -577,6 +581,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 +623,165 @@ 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()}; | ||
| for (const auto& outpoint : outpoints) { | ||
| m_pending_obs.emplace(outpoint, nNow); | ||
| } | ||
|
|
||
| // NOTE: the record which owns these locks has to be written BEFORE the locks | ||
| // themselves. These are two separate writes and a crash in between must not leave | ||
| // persistently locked coins behind with nothing tracking them: nothing would ever | ||
| // release them again. The opposite order is self-healing, CheckPendingObservations() | ||
| // drops a pending entry as soon as it sees its coin is not locked. | ||
| bool fPersisted{batch.WriteCoinJoinPendingObs(m_pending_obs)}; | ||
|
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.
With AGENTS.md reference: AGENTS.md:L169-L171 Useful? React with 👍 / 👎. |
||
| for (const auto& outpoint : outpoints) { | ||
| // The coins are locked in memory already (see PrepareDenominate), this only | ||
| // persists the lock so that a restart before the finalized transaction is | ||
| // observed cannot make the input available for selection again. Skip persisting | ||
| // it if the record above could not be written though, a persistent lock with | ||
| // nothing left to release it strands the input. | ||
| if (!m_wallet->LockCoin(outpoint, fPersisted ? &batch : nullptr)) fPersisted = false; | ||
|
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: Commit the pending record and persistent locks atomically
source: ['codex'] |
||
| WalletCJLogPrint(m_wallet, "CCoinJoinClientManager::%s -- %s is locked until the finalized mixing transaction is observed\n", | ||
| __func__, outpoint.ToStringShort()); | ||
| } | ||
| 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), " /* Continued */ | ||
| "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); | ||
|
|
||
| if (!m_pending_obs_loaded) { | ||
| // Read-only, no need to checkpoint the database on the way out | ||
| wallet::WalletBatch batch_load(m_wallet->GetDatabase(), /*_fFlushOnClose=*/false); | ||
| LoadPendingObservations(batch_load); | ||
| } | ||
|
|
||
| // Constructed on the first write only: this keeps running for as long as anything is | ||
| // pending and every WalletBatch checkpoints the wallet database when it goes out of | ||
| // scope, which is far too expensive to pay for a pass which changes nothing. | ||
| std::optional<wallet::WalletBatch> batch; | ||
| const auto get_batch = [&]() -> wallet::WalletBatch& { | ||
| if (!batch.has_value()) batch.emplace(m_wallet->GetDatabase()); | ||
| return batch.value(); | ||
| }; | ||
|
|
||
| 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, &get_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 >= COINJOIN_PENDING_OBSERVATION_TIMEOUT) { | ||
| // 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, " /* Continued */ | ||
| "releasing lock after %d seconds\n", | ||
| __func__, outpoint.ToStringShort(), COINJOIN_PENDING_OBSERVATION_TIMEOUT); | ||
| m_wallet->UnlockCoin(outpoint, &get_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. Note that this can be | ||
| // terminal: if the wallet never learns about the spend (restored from an old | ||
| // backup and never rescanned, say) the entry stays for good. Releasing it | ||
| // would be wrong - such a coin still looks unspent to the wallet and could be | ||
| // selected for another session - so only refresh the timer to keep the check | ||
| // above from running on every pass. The refresh is deliberately not persisted, | ||
| // the worst a restart can do is re-run the check once. | ||
| WalletCJLogPrint(m_wallet, "CCoinJoinClientManager::%s -- %s is spent in chain/mempool but not according to " /* Continued */ | ||
| "the wallet, keeping it locked\n", | ||
| __func__, outpoint.ToStringShort()); | ||
| it->second = nNow; | ||
| } | ||
| ++it; | ||
| } | ||
| if (fChanged && !get_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; | ||
|
|
@@ -1751,6 +1941,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.
💬 Nitpick: Avoid promising a one-hour maximum lock duration
The one-hour threshold is evaluated only while blockchain synchronization is complete and only on the scheduler's one-minute cadence, so it is not an upper bound. A delayed scheduler pass or an unsynchronized node can retain an otherwise releasable lock for longer than an hour, while an input reported spent by chain or mempool remains locked until the wallet observes the spend. Describe the timeout as a spentness check performed after one hour once synchronized, rather than saying inputs remain locked for "at most an hour."
source: ['codex']