feat: chain-level Sapling turnstile (reject blocks that would drive the pool negative)

Belt-and-suspenders inflation/counterfeiting guard on top of the per-tx Sapling
binding signature: ConnectBlock rejects any block whose cumulative Sapling value
pool would go negative (bad-sapling-value-pool-negative) -- a block can never
deshield more value than was ever shielded.

Enforced ONLY when the pool is reliably tracked from genesis (pprev's
nChainSaplingValue is engaged), so it can never false-reject a valid block or
split the chain on nodes that don't track the pool -- those stay dormant.

To make "not reliably tracked" propagate safely, nSaplingValue becomes a
boost::optional<CAmount> (was a plain CAmount). A version-gated dual-read in
CDiskBlockIndex reads records written before SAPLING_VALUE_OPTIONAL_VERSION
(1000350 = v1.0.3) as the legacy raw 8-byte CAmount but DISCARDS the value
(reads boost::none). Records written at >= 1000350 use the optional format and
persist, so from-genesis and reindexed v1.0.3 nodes are durably active across
restarts.

Tested on a 5-node RandomX fleet: old-format DB loads dormant (0 corruption);
from-genesis stays active with correct pool accumulation and 0 false-rejects
across shield/deshield cycles; dormant/active/reindexed nodes converge; a crafted
counterfeit block is rejected (guard fires, no crash); active state persists
across restart (verified at CLIENT_VERSION 1000351 with gate 1000350).

*** MANDATORY UPGRADE STEP (v1.0.3 dev/test nodes) ***
CLIENT_VERSION stays 1000350, and pre-turnstile v1.0.3 builds ALSO stamped
records at 1000350 but in the old plain-8-byte format. Those records now route to
the OPTIONAL read branch and MISPARSE: LoadBlockIndexDB throws and the node
ABORTS on startup (looks like block-DB corruption). Therefore any node that ran an
earlier v1.0.3 (1000350) build MUST have its block data wiped or be -reindexed
before running this build -- do NOT upgrade a 1000350 datadir in place.
Production mainnet (v1.0.2 = CLIENT_VERSION 1000250) is UNAFFECTED: those records
take the legacy branch and read correctly (dormant until reindex). v1.0.3 is
unreleased, so only dev/test datadirs are affected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 01:14:46 +02:00
parent adf2bacdbd
commit d159e72086
2 changed files with 45 additions and 14 deletions

View File

@@ -3543,6 +3543,19 @@ bool ConnectBlock(const CBlock& block, CValidationState& state, CBlockIndex* pin
error("ConnectBlock(): block's hashFinalSaplingRoot is incorrect"),
REJECT_INVALID, "bad-sapling-root-in-block");
}
// Turnstile / inflation guard (belt-and-suspenders to the per-tx binding-signature check):
// the cumulative Sapling value pool must never go negative -- a block cannot deshield more
// value than was ever shielded. Enforced ONLY when the pool is reliably tracked: pprev's
// nChainSaplingValue is engaged only if every ancestor since genesis had a known per-block
// value (nSaplingValue). On old/snapshot-bootstrapped nodes it is none -> guard dormant
// (until reindex), so this can never false-reject a valid block or split the chain.
if (pindex->pprev && pindex->pprev->nChainSaplingValue) {
CAmount blockSaplingValue = 0;
for (const CTransaction& btx : block.vtx)
blockSaplingValue += -btx.valueBalance;
if (*pindex->pprev->nChainSaplingValue + blockSaplingValue < 0)
return state.DoS(100, error("ConnectBlock(): Sapling value pool would go negative (turnstile/inflation violation)"), REJECT_INVALID, "bad-sapling-value-pool-negative");
}
}
int64_t nTime1 = GetTimeMicros(); nTimeConnect += nTime1 - nTimeStart;
LogPrint("bench", " - Connect %u transactions: %.2fms (%.3fms/tx, %.3fms/txin) [%.2fs]\n", (unsigned)block.vtx.size(), 0.001 * (nTime1 - nTimeStart), 0.001 * (nTime1 - nTimeStart) / block.vtx.size(), nInputs <= 1 ? 0 : 0.001 * (nTime1 - nTimeStart) / (nInputs-1), nTimeConnect * 0.000001);
@@ -4825,8 +4838,8 @@ bool ReceivedBlockTransactions(const CBlock &block, CValidationState& state, CBl
} else {
pindex->nChainSproutValue = boost::none;
}
if (pindex->pprev->nChainSaplingValue) {
pindex->nChainSaplingValue = *pindex->pprev->nChainSaplingValue + pindex->nSaplingValue;
if (pindex->pprev->nChainSaplingValue && pindex->nSaplingValue) {
pindex->nChainSaplingValue = *pindex->pprev->nChainSaplingValue + *pindex->nSaplingValue;
} else {
pindex->nChainSaplingValue = boost::none;
}
@@ -5970,8 +5983,8 @@ bool static LoadBlockIndexDB()
} else {
pindex->nChainSproutValue = boost::none;
}
if (pindex->pprev->nChainSaplingValue) {
pindex->nChainSaplingValue = *pindex->pprev->nChainSaplingValue + pindex->nSaplingValue;
if (pindex->pprev->nChainSaplingValue && pindex->nSaplingValue) {
pindex->nChainSaplingValue = *pindex->pprev->nChainSaplingValue + *pindex->nSaplingValue;
} else {
pindex->nChainSaplingValue = boost::none;
}
@@ -6347,7 +6360,7 @@ bool RewindBlockIndex(const CChainParams& params, bool& clearWitnessCaches)
pindexIter->nChainTx = 0;
pindexIter->nSproutValue = boost::none;
pindexIter->nChainSproutValue = boost::none;
pindexIter->nSaplingValue = 0;
pindexIter->nSaplingValue = boost::none;
pindexIter->nChainSaplingValue = boost::none;
pindexIter->nSequenceId = 0;