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:
36
src/chain.h
36
src/chain.h
@@ -38,6 +38,12 @@ extern bool fZindex;
|
||||
// to 0 after node restart. DragonX CLIENT_VERSION is 1000350 (v1.0.3.50).
|
||||
static const int SPROUT_VALUE_VERSION = 1000000;
|
||||
static const int SAPLING_VALUE_VERSION = 1000000;
|
||||
// Block-index records written at >= this version store nSaplingValue as a boost::optional
|
||||
// (1-byte discriminant + value). Earlier records stored it as a raw 8-byte CAmount; the
|
||||
// deserializer consumes those bytes but reads the value as boost::none (untrusted) so the
|
||||
// turnstile guard stays dormant until such a node reindexes. Keep == the CLIENT_VERSION that
|
||||
// introduced the optional format (v1.0.3, CLIENT_VERSION 1000350).
|
||||
static const int SAPLING_VALUE_OPTIONAL_VERSION = 1000350;
|
||||
extern int32_t ASSETCHAINS_LWMAPOS;
|
||||
extern char SMART_CHAIN_SYMBOL[65];
|
||||
extern uint64_t ASSETCHAINS_NOTARY_PAY[];
|
||||
@@ -373,10 +379,10 @@ public:
|
||||
//! Will be boost::none if nChainTx is zero.
|
||||
boost::optional<CAmount> nChainSproutValue;
|
||||
|
||||
//! Change in value held by the Sapling circuit over this block.
|
||||
//! Not a boost::optional because this was added before Sapling activated, so we can
|
||||
//! rely on the invariant that every block before this was added had nSaplingValue = 0.
|
||||
CAmount nSaplingValue;
|
||||
//! Change in value held by the Sapling circuit over this block. boost::none for blocks
|
||||
//! before nSaplingValue was tracked, or on nodes that loaded an older-format block index
|
||||
//! (see SAPLING_VALUE_OPTIONAL_VERSION) -- propagates to nChainSaplingValue == none.
|
||||
boost::optional<CAmount> nSaplingValue;
|
||||
|
||||
//! (memory only) Total value held by the Sapling circuit up to and including this block.
|
||||
//! Will be boost::none if nChainTx is zero.
|
||||
@@ -460,7 +466,7 @@ public:
|
||||
nSequenceId = 0;
|
||||
nSproutValue = boost::none;
|
||||
nChainSproutValue = boost::none;
|
||||
nSaplingValue = 0;
|
||||
nSaplingValue = boost::none;
|
||||
nChainSaplingValue = boost::none;
|
||||
|
||||
nVersion = 0;
|
||||
@@ -667,10 +673,22 @@ public:
|
||||
READWRITE(nSproutValue);
|
||||
}
|
||||
|
||||
// Only read/write nSaplingValue if the client version used to create
|
||||
// this index was storing them.
|
||||
if ((s.GetType() & SER_DISK) && (nVersion >= SAPLING_VALUE_VERSION)) {
|
||||
READWRITE(nSaplingValue);
|
||||
// nSaplingValue is a boost::optional so "not reliably tracked" reads back as none,
|
||||
// keeping the turnstile guard dormant on old/snapshot-bootstrapped DBs. Records written
|
||||
// before SAPLING_VALUE_OPTIONAL_VERSION stored it as a raw 8-byte CAmount: consume those
|
||||
// bytes for alignment but DISCARD the value (read as none), since it may be understated on
|
||||
// a node that never tracked the pool from genesis. That node stays dormant until it
|
||||
// reindexes, which rewrites records at the current version -> the optional path below.
|
||||
if (s.GetType() & SER_DISK) {
|
||||
if (nVersion >= SAPLING_VALUE_OPTIONAL_VERSION) {
|
||||
READWRITE(nSaplingValue); // new format: boost::optional<CAmount>
|
||||
} else if (nVersion >= SAPLING_VALUE_VERSION) {
|
||||
CAmount nLegacySaplingValue = 0; // old format: raw 8 bytes present in the stream
|
||||
READWRITE(nLegacySaplingValue); // consume for alignment; value is discarded
|
||||
if (ser_action.ForRead())
|
||||
nSaplingValue = boost::none;
|
||||
}
|
||||
// else (< SAPLING_VALUE_VERSION): field was never stored -> nSaplingValue stays none
|
||||
}
|
||||
|
||||
// These values only serialized when -zindex enabled
|
||||
|
||||
23
src/main.cpp
23
src/main.cpp
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user