close
Skip to content

evodb curDBTransaction.IsClean() abort from out-of-transaction credit-pool snapshot write at DISK_SNAPSHOT_PERIOD boundaries #7549

Description

@DashBot-0001

Summary

dashd/dash-qt aborts at flush time with

dashd: evo/evodb.cpp:59: bool CEvoDB::CommitRootTransaction():
       Assertion `curDBTransaction.IsClean()' failed.
Posix Signal: Aborted

when a credit-pool lookup on the chain tip happens outside a dbTx while the
tip is on a DISK_SNAPSHOT_PERIOD (576) boundary. The interrupted evodb state
then fails VerifyDB on restart (*** Found EvoDB inconsistency, you must reindex to continue) even though the chain is at tip, so the node needs a full
-reindex to start again.

Reproduced deterministically on v23.1.8 (release). The relevant code
(CommitRootTransaction, CCreditPoolManager::AddToCache) is unchanged on
master at time of writing, so master is affected too.

Root cause

CCreditPoolManager::AddToCache() writes a snapshot to evodb unconditionally at
every 576-boundary, with no guarantee of being inside a committed transaction:

// evo/creditpool.cpp
void CCreditPoolManager::AddToCache(const uint256& block_hash, int height,
                                    const CCreditPool &pool) {
    { LOCK(cache_mutex); creditPoolCache.insert(block_hash, pool); }
    if (height % DISK_SNAPSHOT_PERIOD == 0) {
        evoDb.Write(std::make_pair(DB_CREDITPOOL_SNAPSHOT, block_hash), pool);
    }
}

evoDb.Write targets the cur layer, which is only committed/rolled back by the
CEvoDBScopedCommitter obtained from BeginTransaction(). AddToCache is
reached from ConstructCreditPoolGetCreditPool, which has four call sites —
only one is inside a dbTx:

Call site In a dbTx?
evo/specialtxman.cpp:573 GetCreditPool(pindex->pprev) (ConnectBlock) yes (safe)
rpc/rawtransaction.cpp:693 GetCreditPool(pTipBlockIndex) (getassetunlockstatuses, no-height branch) no
node/miner.cpp:471 GetCreditPool(pindexPrev) (block assembly) no — but see note
node/interfaces.cpp:433 getCreditPoolCounts (Qt CreditPoolFeed, timer-driven) no

A block's own credit-pool snapshot is written when its successor connects
(ConnectBlock uses pprev). So while the tip is a 576-boundary block, that
block's snapshot is not yet in evodb. An out-of-dbTx GetCreditPool(tip) then
misses both the in-memory cache and the snapshot, runs ConstructCreditPool(tip)
AddToCache(tip) → the bare evoDb.Write lands in the uncommitted cur
layer. Nothing commits or rolls it back, so the next FlushStateToDisk
CommitRootTransaction() fails assert(curDBTransaction.IsClean()). If the next
block connects first, its ConnectBlock dbTx commits cur and the stray write is
harmless — hence this is intermittent rather than a guaranteed crash.

Reproduction (regtest, v23.1.8)

dashd -regtest -datadir=DIR -daemon=1 -fallbackfee=0.0001
dash-cli -regtest generatetoaddress 575 <addr>     # tip 575 (575 % 576 = 575)
dash-cli -regtest getassetunlockstatuses '["1"]'   # no height -> queries tip
dash-cli -regtest stop                             # clean shutdown

dash-cli -regtest generatetoaddress 576 <addr>     # tip 576 (576 % 576 = 0)
dash-cli -regtest getassetunlockstatuses '["1"]'   # no height -> queries tip
dash-cli -regtest stop                             # -> Assertion `IsClean()' failed

Observed matrix (V20 active): 575+call → clean; 576+call → abort;
576+no call → clean; tip 580 + getassetunlockstatuses '["1"]' 576 (explicit
historical boundary height) → clean; getblocktemplate at tip 576 → clean.

Notes on the other out-of-dbTx doors:

  • getblocktemplate (miner.cpp:471) reaches the same bare write but did not
    abort in testing — its template-validity check appears to open and roll back a
    dbTx, whose RollbackCurTransaction()Clear() incidentally wipes the stray
    write. This masking looks accidental.
  • getCreditPoolCounts (interfaces.cpp:433) is polled by the Qt GUI's
    CreditPoolFeed on a timer, so a running dash-qt wallet is expected to hit
    this on its own at 576-boundaries with no user action. (Reasoned from the call
    path; not separately GUI-tested.)

Exploitability

We believe this is not remotely triggerable: the RPC door is credential-gated
and (in the configs we checked) bound to loopback/private ranges; Drive/Platform's
own get_asset_unlock_statuses always passes a chainlocked height, so the DAPI
path takes the safe historical-block branch; and there is no mempool/p2p path
into GetCreditPool. The GUI door is self-inflicted. It reads as a reliability
bug, not a remote DoS. If you consider node aborts security-sensitive per your
SECURITY.md, we're happy to move this to security@dash.org instead.

Possible fixes (for discussion, not prescriptive)

  • Make the snapshot write in AddToCache conditional on being inside a committed
    transaction — read paths compute-and-cache only, durable writes go through the
    same committer ConnectBlock uses. (deterministicmns.cpp's snapshot writer
    only runs from ProcessBlock, always in a dbTx — a useful contrast.)
  • Or wrap the out-of-dbTx GetCreditPool callers in a rolled-back
    BeginTransaction (read-only semantics) so any stray write is cleared — which
    is effectively what getblocktemplate does by accident today.

The assert itself is correct (it prevents writing an inconsistent evodb); the bug
is on the write side. Happy to test any candidate fix against the regtest repro.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions