Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions doc/release-notes-7480.md
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.
Comment on lines +13 to +14

Copy link
Copy Markdown

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']

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.
191 changes: 191 additions & 0 deletions src/coinjoin/client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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>
Expand Down Expand Up @@ -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());
Expand All @@ -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)};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Commit pending records and locks atomically

With -coinjoinautostart=1, a crash after WriteCoinJoinPendingObs commits but before all WriteLockedUTXO calls complete leaves the affected inputs unlocked after restart; DoMaintenance first runs after one second and can select them, while the cleanup that notices the missing lock first runs after one minute. The fresh evidence in this revision is that these remain separate autocommitted database writes, so writing the owner record first only changes which half survives rather than making the operation crash-safe; use a wallet database transaction for the record and all locks.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: Commit the pending record and persistent locks atomically

AddPendingObservation() writes cj_pending_obs first and then writes each lockedutxo separately. WalletBatch explicitly documents that every write is its own transaction unless TxnBegin() is used, so a crash after the pending record is committed but before every lock is written can leave an input durable in the pending set without its protective persistent lock. On restart, the wallet exposes that input as selectable, and CheckPendingObservations() interprets the missing lock as a manual unlock and deletes the recovery record. Commit the pending record and all associated lock records in one explicit wallet-database transaction, aborting the transaction on any write failure.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not mark failed pending reads as loaded

When a wallet already has pending observations and this first lazy database read fails—due to a transient I/O error or an unreadable cj_pending_obs value—the code sets m_pending_obs_loaded before checking the result and subsequently treats the empty or partially deserialized map as authoritative. Future checks never retry, leaving the corresponding persisted lockedutxo records without an owner that can release them; a later successful AddPendingObservation can also overwrite the database record while omitting those older inputs. Only mark the state loaded after a successful read, or distinguish a genuinely absent record from a read/deserialization failure.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retain tracking when persistent unlock fails

If the wallet database returns an I/O error while erasing lockedutxo, CWallet::UnlockCoin has already removed the in-memory lock but returns false; this result is ignored, the pending entry is erased, and the reduced pending map can still be persisted. After restart the surviving lockedutxo record recreates a permanent lock with no pending owner to release it; only erase the observation after the persistent unlock succeeds, including in the timeout branch.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: Acquire chain locks before the wallet lock

CheckPendingObservations() holds m_wallet->cs_wallet from line 676 while calling chain().findCoins(). The node implementation delegates to node::FindCoins(), which acquires cs_main and the mempool lock at src/node/coin.cpp:16, establishing the prohibited order cs_wallet -> cs_main. Once an entry reaches the timeout, the periodic maintenance task can deadlock with a path using the required cs_main -> cs_wallet order. Inspect chain and mempool spentness without holding cs_wallet, then acquire the wallet lock and revalidate the pending entry before changing its lock state.

source: ['codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Lock ordering (cs_wallet → cs_main) — FALSE POSITIVE
The finding's premise is that cs_wallet → cs_main is "the prohibited order." It isn't — it's the established one in this codebase:

wallet/spend.cpp:1170 — FundTransaction takes LOCK(wallet.cs_wallet) eight lines before wallet.chain().findCoins(coins). That's upstream Bitcoin code.
coinjoin/client.cpp:453 — the pre-existing SignFinalTransaction takes cs_wallet, then calls findCoins at line 536.
For the deadlock to be reachable there must be a cs_main → cs_wallet path. I looked for one:

No wallet function asserts or requires cs_main (grep AssertLockHeld(cs_main) / EXCLUSIVE_LOCKS_REQUIRED(cs_main across src/wallet/ returns nothing).
Every validation-interface callback the wallet consumes — TransactionAddedToMempool, BlockConnected, BlockDisconnected, UpdatedBlockTip, ChainStateFlushed, NotifyChainLock, NotifyTransactionLock — goes through ENQUEUE_AND_LOG_EVENT onto the scheduler queue. They run on the scheduler thread with no cs_main held. The wallet's proxy notably uses UpdatedBlockTip, not the synchronous variant.
Empirically: these builds are -DDEBUG_LOCKORDER, which aborts on an observed inversion. wallet_fundrawtransaction.py exercises cs_wallet → cs_main heavily alongside block connection and mempool activity, and passes with no inconsistency reported.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved in f9c4bebAcquire chain locks before the wallet lock no longer present.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check chain and mempool spentness under one lock snapshot

When the timeout check races with the finalized transaction being mined, findCoins() can return the chain output while the transaction is still in the mempool, then block connection can spend the chain output and remove the transaction before the separate mempool.isSpent() call; both operands consequently report "unspent" and the lock is released before the queued wallet notification records the spend. The fresh evidence in the current revision is that the added mempool check uses a second lock acquisition rather than the same cs_main/mempool snapshot as findCoins(); query both states atomically.

AGENTS.md reference: AGENTS.md:L169-L171

Useful? React with 👍 / 👎.

Comment on lines +777 to +779

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: Take an atomic chain-and-mempool spentness snapshot

findCoins() and mempool.isSpent() observe state under separate lock scopes. After findCoins() reports the chain output as unspent, block connection can spend that output and remove its spending transaction from the mempool before isSpent() runs, causing both checks to indicate that it is unspent. The wallet's queued BlockConnected callback cannot update CWallet::IsSpent() while this function holds cs_wallet, so the code can remove the pending entry and protective lock during the pre-wallet-observation window this PR is intended to close. Query chain UTXO existence and mempool mapNextTx spentness through one operation holding cs_main and the mempool lock for a coherent snapshot.

source: ['codex']

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep inputs locked while an ISLock awaits its transaction

When a verified InstantSend lock reaches this node before its transaction and the transaction remains unavailable past the one-hour timeout, NetInstantSend::ProcessInstantSendLock retains that lock in pendingNoTxInstantSendLocks, but this condition sees neither a chain nor mempool spend and releases the wallet lock. The input can then enter another CoinJoin session even though the network has already committed an ISLock for the missing transaction; include pending InstantSend-lock state in the timeout decision rather than treating this case as non-propagation.

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;
Expand Down Expand Up @@ -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()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Load persisted observations before reporting their count

After loading a wallet that has persisted pending observations, m_pending_obs remains empty until CheckPendingObservations() lazily reads the database; because scheduleEvery performs its first cleanup run only after the one-minute interval, getcoinjoininfo incorrectly reports pending_inputs: 0 during that window even though the corresponding coins are already locked. Load the record before answering this RPC or initialize it when the client manager is created.

AGENTS.md reference: AGENTS.md:L169-L171

Useful? React with 👍 / 👎.


UniValue arrSessions(UniValue::VARR);
AssertLockNotHeld(cs_deqsessions);
Expand Down
30 changes: 29 additions & 1 deletion src/coinjoin/client.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@
#include <util/translation.h>

#include <deque>
#include <map>
#include <memory>
#include <ranges>
#include <utility>

namespace wallet {
class WalletBatch;
} // namespace wallet

class CCoinJoinClientManager;
class CConnman;
class CDeterministicMNManager;
Expand Down Expand Up @@ -174,6 +179,21 @@ class CCoinJoinClientManager : public interfaces::CoinJoin::Client
// how many blocks to wait for after one successful mixing tx in non-multisession mode
static constexpr int nMinBlocksToWait{1};

mutable Mutex cs_pending_obs;
//! Inputs of successfully completed mixing sessions which must stay locked until
//! the wallet observes the finalized mixing transaction spending them. Without this
//! a client which processes DSCOMPLETE(MSG_SUCCESS) before the (trickle-relayed)
//! finalized DSTX arrives could select the very same inputs for another session
//! (esp. with -coinjoinmultisession) and double-spend them.
//! Maps outpoint to the time it was added. Mirrored to the wallet database so that
//! both the locks and their grace period survive a restart, and so that these are
//! never confused with locks the user set themselves via `lockunspent`.
std::map<COutPoint, int64_t> m_pending_obs GUARDED_BY(cs_pending_obs);
bool m_pending_obs_loaded GUARDED_BY(cs_pending_obs){false};

/// Populate m_pending_obs from the wallet database, once per run
void LoadPendingObservations(wallet::WalletBatch& batch) EXCLUSIVE_LOCKS_REQUIRED(cs_pending_obs);

// Keep track of current block height
int nCachedBlockHeight{0};

Expand Down Expand Up @@ -211,6 +231,14 @@ class CCoinJoinClientManager : public interfaces::CoinJoin::Client

void UpdatedSuccessBlock();

/// Keep the given successfully mixed inputs locked (persistently) until the wallet
/// observes a transaction spending them
void AddPendingObservation(const std::vector<COutPoint>& outpoints) EXCLUSIVE_LOCKS_REQUIRED(!cs_pending_obs);
/// Release pending inputs whose spend has been observed by the wallet
void CheckPendingObservations(const CTxMemPool& mempool) EXCLUSIVE_LOCKS_REQUIRED(!cs_pending_obs);
bool IsPendingObservation(const COutPoint& outpoint) const EXCLUSIVE_LOCKS_REQUIRED(!cs_pending_obs);
size_t GetPendingObservationCount() const EXCLUSIVE_LOCKS_REQUIRED(!cs_pending_obs);

void UpdatedBlockTip(const CBlockIndex* pindex);

void DoMaintenance(ChainstateManager& chainman, CConnman& connman, const CTxMemPool& mempool)
Expand All @@ -219,7 +247,7 @@ class CCoinJoinClientManager : public interfaces::CoinJoin::Client
// interfaces::CoinJoin::Client overrides
void disableAutobackups() override { fCreateAutoBackups = false; }
void resetPool() override EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions);
UniValue getJsonInfo() const override EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions);
UniValue getJsonInfo() const override EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions, !cs_pending_obs);
std::vector<std::string> getSessionStatuses() const override EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions);
std::string getSessionDenoms() const override EXCLUSIVE_LOCKS_REQUIRED(!cs_deqsessions);
bool isMixing() const override;
Expand Down
4 changes: 4 additions & 0 deletions src/coinjoin/coinjoin.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ static constexpr int COINJOIN_AUTO_TIMEOUT_MIN = 5;
static constexpr int COINJOIN_AUTO_TIMEOUT_MAX = 15;
static constexpr int COINJOIN_QUEUE_TIMEOUT = 30;
static constexpr int COINJOIN_SIGNING_TIMEOUT = 15;
//! How long a successfully mixed input is kept locked while waiting for the finalized
//! mixing transaction before chain/mempool spentness is double-checked and the input is
//! potentially released, in seconds
static constexpr int64_t COINJOIN_PENDING_OBSERVATION_TIMEOUT = 60 * 60;

static constexpr size_t COINJOIN_ENTRY_MAX_SIZE = 9;

Expand Down
22 changes: 22 additions & 0 deletions src/coinjoin/walletman.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class CJWalletManagerImpl final : public CJWalletManager
std::map<const std::string, std::unique_ptr<CCoinJoinClientManager>> m_wallet_manager_map GUARDED_BY(cs_wallet_manager_map);

void DoMaintenance(CConnman& connman) EXCLUSIVE_LOCKS_REQUIRED(!cs_wallet_manager_map);
void CheckPendingObservations() EXCLUSIVE_LOCKS_REQUIRED(!cs_wallet_manager_map);

[[nodiscard]] MessageProcessingResult ProcessDSQueue(NodeId from, CConnman& connman, std::string_view msg_type,
CDataStream& vRecv) EXCLUSIVE_LOCKS_REQUIRED(!cs_ProcessDSQueue, !cs_wallet_manager_map);
Expand Down Expand Up @@ -119,7 +120,19 @@ CJWalletManagerImpl::~CJWalletManagerImpl()

void CJWalletManagerImpl::Schedule(CConnman& connman, CScheduler& scheduler)
{
// Inputs of a successfully completed session stay locked until the finalized mixing
// transaction is observed and those locks are persisted, so they are restored with
// the wallet. Releasing them has to keep running independently of mixing itself:
// even with CoinJoin disabled, mixing stopped or transaction relay unavailable
// (block-only mode, where nothing below is scheduled at all) nothing else would ever
// unlock them. NOTE: no CJWalletManager exists on a masternode (see init.cpp), a
// wallet with pending observations opened there keeps its inputs locked until it is
// opened on a regular node again or the user unlocks them via `lockunspent`.
scheduler.scheduleEvery(std::bind(&CJWalletManagerImpl::CheckPendingObservations, this), std::chrono::minutes{1});

// Mixing needs transaction relay
if (!m_relay_txes) return;

scheduler.scheduleEvery(std::bind(&CJWalletManagerImpl::DoMaintenance, this, std::ref(connman)),
std::chrono::seconds{1});
}
Expand Down Expand Up @@ -205,6 +218,15 @@ void CJWalletManagerImpl::DoMaintenance(CConnman& connman)
}
}

void CJWalletManagerImpl::CheckPendingObservations()
{
if (ShutdownRequested()) return;
LOCK(cs_wallet_manager_map);
for (auto& [_, clientman] : m_wallet_manager_map) {
clientman->CheckPendingObservations(m_mempool);
}
}

MessageProcessingResult CJWalletManagerImpl::processMessage(CNode& pfrom, Chainstate& chainstate, CConnman& connman,
CTxMemPool& mempool, std::string_view msg_type,
CDataStream& vRecv)
Expand Down
1 change: 1 addition & 0 deletions src/rpc/coinjoin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,7 @@ static RPCHelpMan getcoinjoininfo()
{RPCResult::Type::NUM, "denoms_hardcap", "Maximum limit of how many inputs of each denominated amount to create"},
{RPCResult::Type::NUM, "queue_size", "How many queues there are currently on the network"},
{RPCResult::Type::BOOL, "running", "Whether mixing is currently running"},
{RPCResult::Type::NUM, "pending_inputs", "The number of successfully mixed inputs kept locked until the transaction spending them is observed"},
{RPCResult::Type::ARR, "sessions", "",
{
{RPCResult::Type::OBJ, "", "",
Expand Down
Loading
Loading