Skip to content

Commit bced3b7

Browse files
test(coinjoin): cover pending-observation input locking
Cover the pending-observation lifecycle: a user's own lock on a denominated coin is never adopted as pending, pending inputs stay locked and excluded from coin selection, the set is mirrored to the wallet database, an observed spend releases the lock, the terminal timeout only fires on a synced chain, an input spent by a mempool transaction the wallet has not recorded yet is kept locked while a genuinely unspent one is released, and a manual user unlock purges the pending entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 51ecf82 commit bced3b7

1 file changed

Lines changed: 119 additions & 0 deletions

File tree

src/wallet/test/coinjoin_tests.cpp

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
44

55
#include <test/util/setup_common.h>
6+
#include <test/util/txmempool.h>
67

78
#include <coinjoin/client.h>
89
#include <coinjoin/coinjoin.h>
@@ -11,14 +12,18 @@
1112
#include <coinjoin/util.h>
1213
#include <consensus/amount.h>
1314
#include <interfaces/coinjoin.h>
15+
#include <masternode/sync.h>
1416
#include <node/context.h>
1517
#include <util/system.h>
1618
#include <util/translation.h>
1719
#include <policy/settings.h>
20+
#include <util/time.h>
1821
#include <validation.h>
22+
#include <txmempool.h>
1923
#include <wallet/context.h>
2024
#include <wallet/spend.h>
2125
#include <wallet/wallet.h>
26+
#include <wallet/walletdb.h>
2227

2328
#include <boost/test/unit_test.hpp>
2429

@@ -224,6 +229,120 @@ class CTransactionBuilderTestSetup : public TestChain100Setup
224229
}
225230
};
226231

232+
BOOST_FIXTURE_TEST_CASE(coinjoin_pending_observation_tests, CTransactionBuilderTestSetup)
233+
{
234+
// 0.100001 DASH, a valid CoinJoin denomination
235+
constexpr CAmount nDenomAmount{10000100};
236+
BOOST_REQUIRE(CoinJoin::IsDenominatedAmount(nDenomAmount));
237+
CompactTallyItem tallyItem = GetTallyItem({nDenomAmount, nDenomAmount, nDenomAmount, nDenomAmount});
238+
const COutPoint outpointUserLocked = tallyItem.outpoints[0];
239+
const COutPoint outpointPending = tallyItem.outpoints[1];
240+
const COutPoint outpointTimeout = tallyItem.outpoints[2];
241+
const COutPoint outpointInMempool = tallyItem.outpoints[3];
242+
243+
// A denominated coin the user locked themselves, e.g. via `lockunspent`
244+
WITH_LOCK(wallet->cs_wallet, wallet->LockCoin(outpointUserLocked));
245+
246+
BOOST_CHECK(m_node.cj_walletman->doForClient("", [&](CCoinJoinClientManager& cj_man) {
247+
// A user-created lock is never adopted as a pending observation: it has no
248+
// CoinJoin record backing it, so it is left strictly alone
249+
cj_man.CheckPendingObservations(*m_node.mempool);
250+
BOOST_CHECK(!cj_man.IsPendingObservation(outpointUserLocked));
251+
BOOST_CHECK_EQUAL(cj_man.GetPendingObservationCount(), 0);
252+
253+
const int64_t nStart{GetTime()};
254+
SetMockTime(nStart);
255+
cj_man.AddPendingObservation({outpointPending});
256+
BOOST_CHECK(cj_man.IsPendingObservation(outpointPending));
257+
BOOST_CHECK_EQUAL(cj_man.GetPendingObservationCount(), 1);
258+
BOOST_CHECK(WITH_LOCK(wallet->cs_wallet, return wallet->IsLockedCoin(outpointPending)));
259+
260+
// Pending inputs are excluded from coin selection while unrelated inputs are not
261+
{
262+
LOCK(wallet->cs_wallet);
263+
bool fFoundPending{false};
264+
bool fFoundFree{false};
265+
for (const auto& out : AvailableCoinsListUnspent(*wallet).all()) {
266+
fFoundPending |= out.outpoint == outpointPending;
267+
fFoundFree |= out.outpoint == outpointTimeout;
268+
}
269+
BOOST_CHECK(!fFoundPending);
270+
BOOST_CHECK(fFoundFree);
271+
}
272+
273+
// The pending set is mirrored to the wallet database so it survives a restart
274+
{
275+
std::map<COutPoint, int64_t> persisted;
276+
WalletBatch batch(wallet->GetDatabase());
277+
BOOST_REQUIRE(batch.ReadCoinJoinPendingObs(persisted));
278+
BOOST_CHECK_EQUAL(persisted.size(), 1);
279+
BOOST_CHECK(persisted.count(outpointPending) > 0);
280+
BOOST_CHECK_EQUAL(persisted.at(outpointPending), nStart);
281+
}
282+
283+
// Nothing is released while the inputs remain unspent and the timeout has not passed
284+
cj_man.CheckPendingObservations(*m_node.mempool);
285+
BOOST_CHECK_EQUAL(cj_man.GetPendingObservationCount(), 1);
286+
287+
// Once the wallet observes a transaction spending a pending input its lock is dropped
288+
CMutableTransaction mtxSpend;
289+
mtxSpend.vin.emplace_back(outpointPending);
290+
mtxSpend.vout.emplace_back(nDenomAmount - 1000, GetScriptForRawPubKey(coinbaseKey.GetPubKey()));
291+
BOOST_REQUIRE(wallet->AddToWallet(MakeTransactionRef(mtxSpend), TxStateInMempool{}));
292+
cj_man.CheckPendingObservations(*m_node.mempool);
293+
BOOST_CHECK(!cj_man.IsPendingObservation(outpointPending));
294+
BOOST_CHECK(!WITH_LOCK(wallet->cs_wallet, return wallet->IsLockedCoin(outpointPending)));
295+
BOOST_CHECK_EQUAL(cj_man.GetPendingObservationCount(), 0);
296+
297+
// An input whose spending transaction sits in the mempool but has not reached the
298+
// wallet yet must NOT be released: findCoins() reports it as unspent (it only
299+
// knows outputs mempool transactions create, not the ones they spend), so the
300+
// mempool has to be consulted separately
301+
CMutableTransaction mtxMempool;
302+
mtxMempool.vin.emplace_back(outpointInMempool);
303+
mtxMempool.vout.emplace_back(nDenomAmount - 1000, GetScriptForRawPubKey(coinbaseKey.GetPubKey()));
304+
{
305+
LOCK2(::cs_main, m_node.mempool->cs);
306+
m_node.mempool->addUnchecked(TestMemPoolEntryHelper().FromTx(MakeTransactionRef(mtxMempool)));
307+
}
308+
BOOST_REQUIRE(m_node.mempool->isSpent(outpointInMempool));
309+
BOOST_REQUIRE(WITH_LOCK(wallet->cs_wallet, return wallet->GetWalletTx(mtxMempool.GetHash())) == nullptr);
310+
311+
cj_man.AddPendingObservation({outpointTimeout, outpointInMempool});
312+
BOOST_CHECK_EQUAL(cj_man.GetPendingObservationCount(), 2);
313+
SetMockTime(nStart + CCoinJoinClientManager::PENDING_OBSERVATION_TIMEOUT_SECONDS + 1);
314+
315+
// The timeout never fires while the chain is still catching up: the spending
316+
// transaction could be sitting in a block we have not downloaded yet
317+
BOOST_REQUIRE(!m_node.mn_sync->IsBlockchainSynced());
318+
cj_man.CheckPendingObservations(*m_node.mempool);
319+
BOOST_CHECK_EQUAL(cj_man.GetPendingObservationCount(), 2);
320+
BOOST_CHECK(WITH_LOCK(wallet->cs_wallet, return wallet->IsLockedCoin(outpointTimeout)));
321+
322+
m_node.mn_sync->SwitchToNextAsset();
323+
BOOST_REQUIRE(m_node.mn_sync->IsBlockchainSynced());
324+
cj_man.CheckPendingObservations(*m_node.mempool);
325+
326+
// Unspent everywhere - released (with a warning) after the terminal timeout
327+
BOOST_CHECK(!cj_man.IsPendingObservation(outpointTimeout));
328+
BOOST_CHECK(!WITH_LOCK(wallet->cs_wallet, return wallet->IsLockedCoin(outpointTimeout)));
329+
// Spent by an unconfirmed transaction the wallet has not recorded - kept locked
330+
BOOST_CHECK(cj_man.IsPendingObservation(outpointInMempool));
331+
BOOST_CHECK(WITH_LOCK(wallet->cs_wallet, return wallet->IsLockedCoin(outpointInMempool)));
332+
BOOST_CHECK_EQUAL(cj_man.GetPendingObservationCount(), 1);
333+
334+
// A manual unlock (e.g. via lockunspent) purges the pending entry
335+
WITH_LOCK(wallet->cs_wallet, wallet->UnlockCoin(outpointInMempool));
336+
cj_man.CheckPendingObservations(*m_node.mempool);
337+
BOOST_CHECK(!cj_man.IsPendingObservation(outpointInMempool));
338+
BOOST_CHECK_EQUAL(cj_man.GetPendingObservationCount(), 0);
339+
340+
// The user's own lock was never touched throughout
341+
BOOST_CHECK(WITH_LOCK(wallet->cs_wallet, return wallet->IsLockedCoin(outpointUserLocked)));
342+
SetMockTime(0);
343+
}));
344+
}
345+
227346
BOOST_FIXTURE_TEST_CASE(coinjoin_manager_start_stop_tests, CTransactionBuilderTestSetup)
228347
{
229348
BOOST_CHECK(m_node.cj_walletman->doForClient("", [](auto& cj_man) {

0 commit comments

Comments
 (0)