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 ConstructCreditPool ← GetCreditPool, 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.
Summary
dashd/dash-qtaborts at flush time withwhen 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 statethen fails
VerifyDBon restart (*** Found EvoDB inconsistency, you must reindex to continue) even though the chain is at tip, so the node needs a full-reindexto start again.Reproduced deterministically on v23.1.8 (release). The relevant code
(
CommitRootTransaction,CCreditPoolManager::AddToCache) is unchanged onmasterat time of writing, somasteris affected too.Root cause
CCreditPoolManager::AddToCache()writes a snapshot to evodb unconditionally atevery 576-boundary, with no guarantee of being inside a committed transaction:
evoDb.Writetargets thecurlayer, which is only committed/rolled back by theCEvoDBScopedCommitterobtained fromBeginTransaction().AddToCacheisreached from
ConstructCreditPool←GetCreditPool, which has four call sites —only one is inside a dbTx:
evo/specialtxman.cpp:573GetCreditPool(pindex->pprev)(ConnectBlock)rpc/rawtransaction.cpp:693GetCreditPool(pTipBlockIndex)(getassetunlockstatuses, no-heightbranch)node/miner.cpp:471GetCreditPool(pindexPrev)(block assembly)node/interfaces.cpp:433getCreditPoolCounts(QtCreditPoolFeed, timer-driven)A block's own credit-pool snapshot is written when its successor connects
(
ConnectBlockusespprev). So while the tip is a 576-boundary block, thatblock's snapshot is not yet in evodb. An out-of-dbTx
GetCreditPool(tip)thenmisses both the in-memory cache and the snapshot, runs
ConstructCreditPool(tip)→
AddToCache(tip)→ the bareevoDb.Writelands in the uncommittedcurlayer. Nothing commits or rolls it back, so the next
FlushStateToDisk→CommitRootTransaction()failsassert(curDBTransaction.IsClean()). If the nextblock connects first, its ConnectBlock dbTx commits
curand the stray write isharmless — hence this is intermittent rather than a guaranteed crash.
Reproduction (regtest, v23.1.8)
Observed matrix (V20 active):
575+call → clean;576+call → abort;576+no call → clean; tip580+getassetunlockstatuses '["1"]' 576(explicithistorical boundary height) → clean;
getblocktemplateat tip576→ clean.Notes on the other out-of-dbTx doors:
getblocktemplate(miner.cpp:471) reaches the same bare write but did notabort in testing — its template-validity check appears to open and roll back a
dbTx, whose
RollbackCurTransaction()→Clear()incidentally wipes the straywrite. This masking looks accidental.
getCreditPoolCounts(interfaces.cpp:433) is polled by the Qt GUI'sCreditPoolFeedon a timer, so a running dash-qt wallet is expected to hitthis 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_statusesalways passes a chainlocked height, so the DAPIpath 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 reliabilitybug, not a remote DoS. If you consider node aborts security-sensitive per your
SECURITY.md, we're happy to move this to
security@dash.orginstead.Possible fixes (for discussion, not prescriptive)
AddToCacheconditional on being inside a committedtransaction — read paths compute-and-cache only, durable writes go through the
same committer
ConnectBlockuses. (deterministicmns.cpp's snapshot writeronly runs from
ProcessBlock, always in a dbTx — a useful contrast.)GetCreditPoolcallers in a rolled-backBeginTransaction(read-only semantics) so any stray write is cleared — whichis effectively what
getblocktemplatedoes 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.