Skip to content

Commit e774bab

Browse files
committed
fix(coinjoin): persist the locks of pending observations added while the record was unreadable
AddPendingObservation() locks its inputs in memory only when the pending-observation record cannot be read or no database transaction can be started. Once the record is readable again nothing ever writes those locks: CheckPendingObservations() writes only the record and only after a release, and the next AddPendingObservation() writes locks only for its own inputs. The record then lists inputs without a persistent lock. After a restart the missing lock reads as a manual unlock, the entry is dropped and the input becomes selectable again while the finalized mixing transaction spending it may still be in flight. Move the transactional write into PersistPendingObservations(), which writes the record together with the lock of every entry whose coin is still locked, and use it on both the add and the release path. CheckPendingObservations() also runs it when the record was recovered on this pass while entries were already held in memory. Rewriting a lock which is already persisted is idempotent and the set only holds the inputs of recent sessions. The existing unreadable-record test now re-adds an input before the record is made readable and checks that its lock is persisted by the recovering check pass.
1 parent 56a3ae7 commit e774bab

3 files changed

Lines changed: 44 additions & 118 deletions

File tree

‎src/coinjoin/client.cpp‎

Lines changed: 38 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,33 @@ void CCoinJoinClientManager::UpdatedSuccessBlock()
678678
nCachedLastSuccessBlock = nCachedBlockHeight;
679679
}
680680

681+
// NOTE: the record which owns the locks and the persistent locks themselves are
682+
// separate writes and each write is its own implicit transaction, so commit them in
683+
// one explicit database transaction: a crash in between must neither leave
684+
// persistently locked coins behind with nothing tracking them (nothing would ever
685+
// release them again) nor persist the record without its locks (on restart the
686+
// missing lock reads as a manual unlock, the entry is dropped and the input becomes
687+
// selectable again while the finalized mixing transaction may still be in flight).
688+
// If no transaction can be started, don't persist anything rather than risk exactly
689+
// those partial states. The locks of all entries are written, not only of the ones
690+
// just added: entries added while nothing could be persisted are locked in memory only.
691+
// A lock released manually in the meantime is left alone, the next check drops its entry.
692+
static bool PersistPendingObservations(const CWallet& wallet, wallet::WalletBatch& batch,
693+
const std::map<COutPoint, int64_t>& pending)
694+
EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet)
695+
{
696+
if (!batch.TxnBegin()) return false;
697+
bool fPersisted{batch.WriteCoinJoinPendingObs(pending)};
698+
for (auto it = pending.begin(); fPersisted && it != pending.end(); ++it) {
699+
if (wallet.IsLockedCoin(it->first)) fPersisted = batch.WriteLockedUTXO(it->first);
700+
}
701+
if (!fPersisted) {
702+
batch.TxnAbort();
703+
return false;
704+
}
705+
return batch.TxnCommit();
706+
}
707+
681708
void CCoinJoinClientManager::AddPendingObservation(const std::vector<COutPoint>& outpoints)
682709
{
683710
AssertLockNotHeld(cs_pending_obs);
@@ -698,54 +725,19 @@ void CCoinJoinClientManager::AddPendingObservation(const std::vector<COutPoint>&
698725
WalletCJLogPrint(m_wallet, "CCoinJoinClientManager::%s -- %s is locked until the finalized mixing transaction is observed\n",
699726
__func__, outpoint.ToStringShort());
700727
}
701-
if (!PersistPendingObservations(batch)) {
728+
// Never overwrite a record which could not be read (LoadPendingObservations() left
729+
// m_pending_obs_loaded unset), that would permanently orphan the locks it still tracks
730+
if (!m_pending_obs_loaded || !PersistPendingObservations(*m_wallet, batch, m_pending_obs)) {
702731
// The in-memory lock still protects these inputs for as long as this process
703-
// runs, but a restart before CheckPendingObservations() manages to persist them
704-
// would make them selectable again while a valid mixing transaction spending them
705-
// may already be in flight. Nothing more we can do about it here beyond making
706-
// the failure loud - the wallet database is broken.
732+
// runs, but a restart would make them selectable again while a valid mixing
733+
// transaction spending them may already be in flight. Nothing we can do about
734+
// it here beyond making the failure loud - the wallet database is broken.
707735
LogPrintf("CCoinJoinClientManager::%s -- ERROR: failed to persist locks for %d successfully mixed input(s), " /* Continued */
708-
"they will not survive a restart until this succeeds\n",
736+
"they will not survive a restart\n",
709737
__func__, outpoints.size());
710738
}
711739
}
712740

713-
bool CCoinJoinClientManager::PersistPendingObservations(wallet::WalletBatch& batch)
714-
{
715-
AssertLockHeld(m_wallet->cs_wallet);
716-
AssertLockHeld(cs_pending_obs);
717-
718-
// NOTE: the record which owns the locks and the persistent locks themselves are
719-
// separate writes and each write is its own implicit transaction, so commit them in
720-
// one explicit database transaction: a crash in between must neither leave
721-
// persistently locked coins behind with nothing tracking them (nothing would ever
722-
// release them again) nor persist the record without its locks (on restart the
723-
// missing lock reads as a manual unlock, the entry is dropped and the input becomes
724-
// selectable again while the finalized mixing transaction may still be in flight).
725-
// If no transaction can be started, don't persist anything rather than risk exactly
726-
// those partial states. Likewise if the existing record could not be read
727-
// (LoadPendingObservations() left m_pending_obs_loaded unset): overwriting it would
728-
// permanently orphan the locks it still tracks.
729-
if (!m_pending_obs_loaded || !batch.TxnBegin()) {
730-
m_pending_obs_dirty = true;
731-
return false;
732-
}
733-
// Write the locks of all entries, not only of the ones just added: entries added
734-
// while nothing could be persisted are locked in memory only. A lock released
735-
// manually in the meantime is left alone, the next check drops its entry.
736-
bool fPersisted{batch.WriteCoinJoinPendingObs(m_pending_obs)};
737-
for (auto it = m_pending_obs.begin(); fPersisted && it != m_pending_obs.end(); ++it) {
738-
if (m_wallet->IsLockedCoin(it->first)) fPersisted = m_wallet->LockCoin(it->first, &batch);
739-
}
740-
if (fPersisted) {
741-
fPersisted = batch.TxnCommit();
742-
} else {
743-
batch.TxnAbort();
744-
}
745-
m_pending_obs_dirty = !fPersisted;
746-
return fPersisted;
747-
}
748-
749741
void CCoinJoinClientManager::LoadPendingObservations(wallet::WalletBatch& batch)
750742
{
751743
AssertLockHeld(cs_pending_obs);
@@ -798,6 +790,9 @@ void CCoinJoinClientManager::CheckPendingObservations(const CTxMemPool& mempool)
798790
LOCK(m_wallet->cs_wallet);
799791
LOCK(cs_pending_obs);
800792

793+
// Entries added while the record could not be read are locked in memory only (see
794+
// AddPendingObservation()), persist them once the record has been recovered
795+
bool fChanged{!m_pending_obs_loaded && !m_pending_obs.empty()};
801796
if (!m_pending_obs_loaded) {
802797
// Read-only, no need to checkpoint the database on the way out
803798
wallet::WalletBatch batch_load(m_wallet->GetDatabase(), /*_fFlushOnClose=*/false);
@@ -815,7 +810,6 @@ void CCoinJoinClientManager::CheckPendingObservations(const CTxMemPool& mempool)
815810

816811
const int64_t nNow{GetTime()};
817812
const bool fSynced{m_mn_sync.IsBlockchainSynced()};
818-
bool fChanged{false};
819813
for (auto it = m_pending_obs.begin(); it != m_pending_obs.end();) {
820814
const COutPoint& outpoint = it->first;
821815
if (!m_wallet->IsLockedCoin(outpoint)) {
@@ -877,16 +871,7 @@ void CCoinJoinClientManager::CheckPendingObservations(const CTxMemPool& mempool)
877871
// it may still track locks nothing else would ever release. An entry released
878872
// above but left in such a record self-heals: once the record is readable again
879873
// the entry reloads, its coin is no longer locked and it is dropped right here.
880-
if (!m_pending_obs_loaded) return;
881-
bool fPersisted{true};
882-
if (m_pending_obs_dirty) {
883-
// Some entries could not be persisted when they were added (e.g. the record could
884-
// not be read back then) and are only locked in memory, persist their locks too
885-
fPersisted = PersistPendingObservations(get_batch());
886-
} else if (fChanged) {
887-
fPersisted = get_batch().WriteCoinJoinPendingObs(m_pending_obs);
888-
}
889-
if (!fPersisted) {
874+
if (fChanged && m_pending_obs_loaded && !PersistPendingObservations(*m_wallet, get_batch(), m_pending_obs)) {
890875
LogPrintf("CCoinJoinClientManager::%s -- ERROR: failed to persist %d pending observation(s)\n", __func__,
891876
m_pending_obs.size());
892877
}

‎src/coinjoin/client.h‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,14 +217,9 @@ class CCoinJoinClientManager : public interfaces::CoinJoin::Client
217217
//! Whether the failure to read the record has been reported already, the read is
218218
//! retried for as long as this node runs and a corrupt record never becomes readable
219219
bool m_pending_obs_load_failed GUARDED_BY(cs_pending_obs){false};
220-
//! Whether m_pending_obs holds entries whose locks are not persisted yet
221-
bool m_pending_obs_dirty GUARDED_BY(cs_pending_obs){false};
222220

223221
/// Populate m_pending_obs from the wallet database, once per run
224222
void LoadPendingObservations(wallet::WalletBatch& batch) EXCLUSIVE_LOCKS_REQUIRED(cs_pending_obs);
225-
/// Persist m_pending_obs along with the locks of its entries in one database transaction
226-
bool PersistPendingObservations(wallet::WalletBatch& batch)
227-
EXCLUSIVE_LOCKS_REQUIRED(m_wallet->cs_wallet, cs_pending_obs);
228223

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

‎src/wallet/test/coinjoin_tests.cpp‎

Lines changed: 6 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
#include <wallet/context.h>
2323
#include <wallet/db.h>
2424
#include <wallet/spend.h>
25-
#include <wallet/test/util.h>
2625
#include <wallet/wallet.h>
2726
#include <wallet/walletdb.h>
2827

@@ -448,14 +447,18 @@ BOOST_FIXTURE_TEST_CASE(coinjoin_pending_observation_unreadable_tests, CTransact
448447
}
449448

450449
// Once the record is readable again the entry it tracks is picked back up and the
451-
// lock behind it can finally be released
450+
// lock behind it can finally be released. An entry added in the meantime is only
451+
// locked in memory until then, its lock is persisted along with the record.
452+
cj_man.AddPendingObservation({outpointInMemory});
452453
{
453454
WalletBatch batch(wallet->GetDatabase());
454455
BOOST_REQUIRE(batch.WriteCoinJoinPendingObs({{outpointPersisted, nStart}}));
455456
}
456457
cj_man.CheckPendingObservations(*m_node.mempool);
457458
BOOST_CHECK(cj_man.IsPendingObservation(outpointPersisted));
458-
BOOST_CHECK_EQUAL(cj_man.GetPendingObservationCount(), 1);
459+
BOOST_CHECK_EQUAL(cj_man.GetPendingObservationCount(), 2);
460+
BOOST_CHECK(wallet->GetDatabase().MakeBatch()->Exists(
461+
std::make_pair(DBKeys::LOCKED_UTXO, std::make_pair(outpointInMemory.hash, outpointInMemory.n))));
459462

460463
m_node.mn_sync->SwitchToNextAsset();
461464
BOOST_REQUIRE(m_node.mn_sync->IsBlockchainSynced());
@@ -467,63 +470,6 @@ BOOST_FIXTURE_TEST_CASE(coinjoin_pending_observation_unreadable_tests, CTransact
467470
SetMockTime(0);
468471
}
469472

470-
BOOST_FIXTURE_TEST_CASE(coinjoin_pending_observation_recovered_tests, CTransactionBuilderTestSetup)
471-
{
472-
// 0.100001 DASH, a valid CoinJoin denomination
473-
constexpr CAmount nDenomAmount{10000100};
474-
CompactTallyItem tallyItem = GetTallyItem({nDenomAmount, nDenomAmount});
475-
const COutPoint outpointPersisted = tallyItem.outpoints[0];
476-
const COutPoint outpointInMemory = tallyItem.outpoints[1];
477-
const auto has_persistent_lock = [&](WalletDatabase& database, const COutPoint& outpoint) {
478-
return database.MakeBatch()->Exists(std::make_pair(DBKeys::LOCKED_UTXO, std::make_pair(outpoint.hash, outpoint.n)));
479-
};
480-
481-
const int64_t nStart{GetTime()};
482-
SetMockTime(nStart);
483-
BOOST_CHECK(m_node.cj_walletman->doForClient("", [&](CCoinJoinClientManager& cj_man) {
484-
cj_man.AddPendingObservation({outpointPersisted});
485-
}));
486-
BOOST_REQUIRE(wallet->GetDatabase().MakeBatch()->Write(std::string(DBKeys::COINJOIN_PENDING_OBS),
487-
std::string("not a pending observation map")));
488-
m_node.cj_walletman->removeWallet(wallet->GetName());
489-
m_node.cj_walletman->addWallet(wallet);
490-
491-
BOOST_CHECK(m_node.cj_walletman->doForClient("", [&](CCoinJoinClientManager& cj_man) {
492-
// While the record is unreadable a new observation is only locked in memory
493-
cj_man.CheckPendingObservations(*m_node.mempool);
494-
cj_man.AddPendingObservation({outpointInMemory});
495-
BOOST_CHECK(cj_man.IsPendingObservation(outpointInMemory));
496-
BOOST_CHECK(WITH_LOCK(wallet->cs_wallet, return wallet->IsLockedCoin(outpointInMemory)));
497-
BOOST_CHECK(!has_persistent_lock(wallet->GetDatabase(), outpointInMemory));
498-
499-
// Once the record is readable again the entries which could not be persisted
500-
// before have to be persisted along with their locks
501-
{
502-
WalletBatch batch(wallet->GetDatabase());
503-
BOOST_REQUIRE(batch.WriteCoinJoinPendingObs({{outpointPersisted, nStart}}));
504-
}
505-
cj_man.CheckPendingObservations(*m_node.mempool);
506-
BOOST_CHECK(cj_man.IsPendingObservation(outpointPersisted));
507-
BOOST_CHECK(cj_man.IsPendingObservation(outpointInMemory));
508-
}));
509-
510-
// Load a copy of the wallet database, the way a restart would: both inputs must
511-
// still be locked and tracked
512-
DatabaseOptions options;
513-
const auto reloaded = std::make_shared<CWallet>(m_node.chain.get(), /*coinjoin_loader=*/nullptr, "", m_args,
514-
DuplicateMockDatabase(wallet->GetDatabase(), options));
515-
BOOST_REQUIRE_EQUAL(reloaded->LoadWallet(), DBErrors::LOAD_OK);
516-
for (const auto& outpoint : {outpointPersisted, outpointInMemory}) {
517-
BOOST_CHECK(has_persistent_lock(reloaded->GetDatabase(), outpoint));
518-
BOOST_CHECK(WITH_LOCK(reloaded->cs_wallet, return reloaded->IsLockedCoin(outpoint)));
519-
}
520-
std::map<COutPoint, int64_t> persisted;
521-
BOOST_REQUIRE(WalletBatch(reloaded->GetDatabase()).ReadCoinJoinPendingObs(persisted));
522-
BOOST_CHECK_EQUAL(persisted.size(), 2);
523-
BOOST_CHECK(persisted.count(outpointInMemory) > 0);
524-
SetMockTime(0);
525-
}
526-
527473
BOOST_FIXTURE_TEST_CASE(coinjoin_manager_start_stop_tests, CTransactionBuilderTestSetup)
528474
{
529475
BOOST_CHECK(m_node.cj_walletman->doForClient("", [](auto& cj_man) {

0 commit comments

Comments
 (0)