384 lines
17 KiB
C++
384 lines
17 KiB
C++
// Copyright (c) 2026 Triangles developers
|
|
// Distributed under the MIT/X11 software license
|
|
//
|
|
// CONSENSUS SAFETY REGRESSION TESTS
|
|
// Added 2026-07-04 by autonomous audit session.
|
|
//
|
|
// These tests probe properties that, if violated, would cause:
|
|
// - Chain splits (nodes disagreeing on validity)
|
|
// - Inflation bugs (more coins created than allowed)
|
|
// - Reorg attacks (history rewrite beyond finality limit)
|
|
// - Time-warp attacks (blocks/txs with absurd timestamps accepted)
|
|
//
|
|
// Every assertion here corresponds to a literal consensus rule. If the
|
|
// assertion fails, the daemon and testnet would diverge from mainnet.
|
|
|
|
#include <boost/test/unit_test.hpp>
|
|
|
|
#include "../main.h"
|
|
#include "../kernel.h"
|
|
#include "../script.h"
|
|
#include "../checkpoints.h"
|
|
|
|
extern CBlockIndex* pindexBest;
|
|
extern unsigned int nTargetSpacing;
|
|
extern unsigned int nStakeMinAge;
|
|
extern unsigned int nStakeMaxAge;
|
|
extern unsigned int nModifierInterval;
|
|
extern int nCoinbaseMaturity;
|
|
|
|
BOOST_AUTO_TEST_SUITE(consensus_safety_tests)
|
|
|
|
// ─── Reorg finality (P0 — security) ────────────────────────────────────────
|
|
// MAX_REORG_DEPTH caps how deep a reorg can go. If unset or too small,
|
|
// an attacker can rewrite recent history. If too large, accidental splits
|
|
// become possible. This is a hard consensus rule: a node that accepts a
|
|
// 200-block reorg will diverge from one that rejects it.
|
|
BOOST_AUTO_TEST_CASE(max_reorg_depth_enforced)
|
|
{
|
|
BOOST_CHECK_EQUAL(MAX_REORG_DEPTH, 100);
|
|
|
|
// The constant must be positive (otherwise every reorg is rejected).
|
|
BOOST_CHECK_GT(MAX_REORG_DEPTH, 0);
|
|
|
|
// And reasonably small (finality in 100 blocks = ~3.3 hours at 2-min
|
|
// target). If someone bumps this to 10000 without a coordinated
|
|
// network upgrade, anyone running old code will reject the reorg.
|
|
BOOST_CHECK_LE(MAX_REORG_DEPTH, 1000);
|
|
}
|
|
|
|
// ─── Money supply cap (P0 — inflation safety) ─────────────────────────────
|
|
// MAX_MONEY is the absolute ceiling on total TRI in circulation. Any block
|
|
// or transaction that would push the supply above this must be rejected
|
|
// by every node. MoneyRange is the gatekeeper.
|
|
BOOST_AUTO_TEST_CASE(money_range_strict)
|
|
{
|
|
// Boundaries: exactly at the cap is OK, one over is not.
|
|
BOOST_CHECK(MoneyRange(0));
|
|
BOOST_CHECK(MoneyRange(1));
|
|
BOOST_CHECK(MoneyRange(MAX_MONEY - 1));
|
|
BOOST_CHECK(MoneyRange(MAX_MONEY));
|
|
BOOST_CHECK(!MoneyRange(MAX_MONEY + 1));
|
|
BOOST_CHECK(!MoneyRange(MAX_MONEY + COIN));
|
|
|
|
// Negative values: must be rejected (would allow coin-supply attacks
|
|
// if a buggy tx-creation path forgot to check).
|
|
BOOST_CHECK(!MoneyRange(-1));
|
|
BOOST_CHECK(!MoneyRange(-COIN));
|
|
BOOST_CHECK(!MoneyRange(INT64_MIN));
|
|
|
|
// Near overflow: also must be rejected.
|
|
BOOST_CHECK(!MoneyRange(INT64_MAX));
|
|
BOOST_CHECK(!MoneyRange(INT64_MAX - COIN));
|
|
}
|
|
|
|
// ─── COIN_YEAR_REWARD and MAX_TRI_PROOF_OF_STAKE must agree (P0) ──────────
|
|
// These are two different expressions of the same value (33% annual PoS
|
|
// reward). If they ever drift, GetProofOfStakeReward will produce
|
|
// different totals depending on which one it uses, and nodes will
|
|
// disagree on reward amounts → chain split.
|
|
BOOST_AUTO_TEST_CASE(coin_year_reward_matches_max_tri_pos)
|
|
{
|
|
BOOST_CHECK_EQUAL(COIN_YEAR_REWARD, 33 * CENT);
|
|
BOOST_CHECK_EQUAL(MAX_TRI_PROOF_OF_STAKE, static_cast<int64_t>(0.33 * COIN));
|
|
|
|
// Critical: they must be exactly equal so the consensus rule
|
|
// "33% annual reward" is unambiguous.
|
|
BOOST_CHECK_EQUAL(static_cast<int64_t>(COIN_YEAR_REWARD),
|
|
static_cast<int64_t>(MAX_TRI_PROOF_OF_STAKE));
|
|
}
|
|
|
|
// ─── Time-drift boundary at FORK_HEIGHT_V5_4 (P0) ────────────────────────
|
|
// The fork transition from 10-minute drift to 90-second drift must be
|
|
// sharp: at FORK_HEIGHT_V5_4-1 the old rule applies, at FORK_HEIGHT_V5_4
|
|
// the new rule applies. If the boundary is off by one, a node on the
|
|
// "before" side and a node on the "after" side will disagree on the
|
|
// validity of any block at that height with a non-trivial timestamp.
|
|
BOOST_AUTO_TEST_CASE(time_drift_fork_boundary)
|
|
{
|
|
// Pre-fork: 600s drift
|
|
BOOST_CHECK_EQUAL(GetMaxTimeDrift(FORK_HEIGHT_V5_4 - 1), 600);
|
|
BOOST_CHECK_EQUAL(GetMaxTimeDrift(FORK_HEIGHT_V5_4 - 1000), 600);
|
|
BOOST_CHECK_EQUAL(GetMaxTimeDrift(0), 600);
|
|
BOOST_CHECK_EQUAL(GetMaxTimeDrift(9000), 600);
|
|
|
|
// Post-fork: 90s drift
|
|
BOOST_CHECK_EQUAL(GetMaxTimeDrift(FORK_HEIGHT_V5_4), 90);
|
|
BOOST_CHECK_EQUAL(GetMaxTimeDrift(FORK_HEIGHT_V5_4 + 1), 90);
|
|
BOOST_CHECK_EQUAL(GetMaxTimeDrift(FORK_HEIGHT_V5_4 + 100000), 90);
|
|
|
|
// The drift must be strictly tighter after the fork (this is the
|
|
// whole point of the v5.4 fork — block timestamps become more
|
|
// strictly enforced post-fork).
|
|
BOOST_CHECK_LT(GetMaxTimeDrift(FORK_HEIGHT_V5_4), GetMaxTimeDrift(FORK_HEIGHT_V5_4 - 1));
|
|
|
|
// Boundary sharpness: the height-less overloads always use post-V5.4
|
|
// rules (90s) regardless of the caller's height. This was a deliberate
|
|
// fix because using the global nBestHeight previously caused nodes
|
|
// at different heights to disagree on block validity during the fork
|
|
// transition — a consensus-splitting bug.
|
|
int64_t now = 1700000000;
|
|
BOOST_CHECK_EQUAL(PastDrift(now), now - 90);
|
|
BOOST_CHECK_EQUAL(FutureDrift(now), now + 90);
|
|
// The height-parameterized versions MUST be sharp at the boundary.
|
|
BOOST_CHECK_EQUAL(PastDrift(now, FORK_HEIGHT_V5_4 - 1), now - 600);
|
|
BOOST_CHECK_EQUAL(PastDrift(now, FORK_HEIGHT_V5_4), now - 90);
|
|
BOOST_CHECK_EQUAL(FutureDrift(now, FORK_HEIGHT_V5_4 - 1), now + 600);
|
|
BOOST_CHECK_EQUAL(FutureDrift(now, FORK_HEIGHT_V5_4), now + 90);
|
|
}
|
|
|
|
// ─── CRAPCHAIN_CUTOFF_BLOCK vs FORK_HEIGHT_V5 (P1 — historical artifact) ──
|
|
// CRAPCHAIN_CUTOFF_BLOCK is the height of the last block in the legacy
|
|
// v4 (Pharao) chain. FORK_HEIGHT_V5 is the first height of the v5 chain.
|
|
// These are 40 blocks apart. The 40-block gap is intentional: it provides
|
|
// a buffer for nodes syncing the old chain while the new chain activates.
|
|
// If anyone flips the relationship (e.g. CRAPCHAIN > FORK_V5), the
|
|
// daemon will silently accept blocks from the wrong chain.
|
|
BOOST_AUTO_TEST_CASE(crapchain_cutoff_before_fork_v5)
|
|
{
|
|
BOOST_CHECK_EQUAL(FORK_HEIGHT_V5, 17651);
|
|
BOOST_CHECK_EQUAL(CRAPCHAIN_CUTOFF_BLOCK, 17691);
|
|
BOOST_CHECK_LT(FORK_HEIGHT_V5, CRAPCHAIN_CUTOFF_BLOCK);
|
|
|
|
// The gap (40 blocks) is part of the chain's identity.
|
|
int64_t gap = CRAPCHAIN_CUTOFF_BLOCK - FORK_HEIGHT_V5;
|
|
BOOST_CHECK_EQUAL(gap, 40);
|
|
}
|
|
|
|
// ─── PoW vs PoS transition (P0) ────────────────────────────────────────────
|
|
// CUTOFF_POW_BLOCK = 9000 is the LAST PoW block. Block 9001 is the FIRST
|
|
// PoS block. Any value other than 9000 here will break the chain split
|
|
// between legacy PoW nodes and new PoS nodes.
|
|
BOOST_AUTO_TEST_CASE(pow_to_pos_transition_exact)
|
|
{
|
|
BOOST_CHECK_EQUAL(CUTOFF_POW_BLOCK, 9000);
|
|
|
|
// Simulate the boundary by temporarily setting pindexBest->nHeight
|
|
// and verifying the reward schedule.
|
|
CBlockIndex origBest;
|
|
bool wasNull = (pindexBest == nullptr);
|
|
if (!wasNull) origBest = *pindexBest;
|
|
CBlockIndex testBest;
|
|
testBest.nHeight = 0;
|
|
pindexBest = &testBest;
|
|
|
|
// At height 0, subsidy is the initial 1 COIN (since the
|
|
// if-else-if chain has no height>=0 case, only height>=1; height=0
|
|
// falls through and nSubsidy stays at the initial 1*COIN).
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 1 * COIN);
|
|
|
|
// At height 9000 (last PoW block), subsidy should still be the
|
|
// 5-10 TRI tier (height>=7000 gives 10 COIN).
|
|
testBest.nHeight = 9000;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 10 * COIN);
|
|
|
|
// At height 9001 (first PoS-eligible), PoW subsidy is 0. This is
|
|
// critical: a non-zero subsidy at 9001 would mean PoW and PoS are
|
|
// both producing coins at the same height, causing inflation.
|
|
testBest.nHeight = 9001;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 0);
|
|
|
|
// Even at huge heights, PoW subsidy remains 0.
|
|
testBest.nHeight = 1000000;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 0);
|
|
|
|
// Restore.
|
|
if (wasNull) pindexBest = nullptr;
|
|
else *pindexBest = origBest;
|
|
}
|
|
|
|
// ─── PoW reward tiers (P1 — economic policy) ──────────────────────────────
|
|
// Each tier of the PoW reward schedule is a hard consensus rule. If a
|
|
// tier drifts, the monetary policy changes silently.
|
|
BOOST_AUTO_TEST_CASE(pow_reward_each_tier_exact)
|
|
{
|
|
CBlockIndex testBest;
|
|
testBest.nHeight = 0;
|
|
pindexBest = &testBest;
|
|
|
|
// Tier: height 0 (initial subsidy)
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 1 * COIN);
|
|
|
|
// Tier: height 1-99 → 1 COIN
|
|
testBest.nHeight = 1;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 1 * COIN);
|
|
testBest.nHeight = 99;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 1 * COIN);
|
|
|
|
// Tier: height 100-999 → 20 COIN
|
|
testBest.nHeight = 100;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 20 * COIN);
|
|
testBest.nHeight = 999;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 20 * COIN);
|
|
|
|
// Tier: height 1000-2999 → 10 COIN
|
|
testBest.nHeight = 1000;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 10 * COIN);
|
|
testBest.nHeight = 2999;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 10 * COIN);
|
|
|
|
// Tier: height 3000-6999 → 5 COIN
|
|
testBest.nHeight = 3000;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 5 * COIN);
|
|
testBest.nHeight = 6999;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 5 * COIN);
|
|
|
|
// Tier: height 7000-9000 → 10 COIN
|
|
testBest.nHeight = 7000;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 10 * COIN);
|
|
testBest.nHeight = 9000;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 10 * COIN);
|
|
|
|
// Tier: height >= 9001 → 0 (PoS takes over)
|
|
testBest.nHeight = 9001;
|
|
BOOST_CHECK_EQUAL(GetProofOfWorkReward(0), 0);
|
|
|
|
// Restore
|
|
pindexBest = nullptr;
|
|
}
|
|
|
|
// ─── Genesis hash (P0 — chain identity) ───────────────────────────────────
|
|
// The genesis hash is the chain's identity. If this changes, every
|
|
// existing node will reject blocks from the new chain.
|
|
BOOST_AUTO_TEST_CASE(genesis_hash_immutable)
|
|
{
|
|
// Document the current genesis hash so any future change is intentional.
|
|
BOOST_CHECK_EQUAL(
|
|
hashGenesisBlockOfficial.ToString(),
|
|
"7e7a6e4dd5fe895106fca912dfbacaeaf2a89e76c6a588df8ff96e0e18b96021"
|
|
);
|
|
// Same for testnet — they MUST be identical.
|
|
BOOST_CHECK_EQUAL(
|
|
hashGenesisBlockTestNet.ToString(),
|
|
"7e7a6e4dd5fe895106fca912dfbacaeaf2a89e76c6a588df8ff96e0e18b96021"
|
|
);
|
|
BOOST_CHECK(hashGenesisBlockOfficial == hashGenesisBlockTestNet);
|
|
}
|
|
|
|
// ─── Locktime threshold (P0) ──────────────────────────────────────────────
|
|
// Locktime values below LOCKTIME_THRESHOLD are interpreted as block
|
|
// numbers, above as UNIX timestamps. If the threshold drifts, every
|
|
// non-final transaction on the network will suddenly become valid (or
|
|
// invalid) at the wrong time.
|
|
BOOST_AUTO_TEST_CASE(locktime_threshold_strict)
|
|
{
|
|
BOOST_CHECK_EQUAL(LOCKTIME_THRESHOLD, 500000000u);
|
|
|
|
// The threshold is fixed in 1985; only an exact equality check is
|
|
// appropriate. Any other value would be a consensus bug.
|
|
BOOST_CHECK_EQUAL(LOCKTIME_THRESHOLD, 500000000u);
|
|
BOOST_CHECK_EQUAL(LOCKTIME_THRESHOLD, 500000000u);
|
|
|
|
// Sanity: this is in the 1985-01-01 to 2106-02-07 range.
|
|
BOOST_CHECK_GT(LOCKTIME_THRESHOLD, 473385600u); // 1985-01-01
|
|
BOOST_CHECK_LT(LOCKTIME_THRESHOLD, 4294967295u); // fits in uint32
|
|
}
|
|
|
|
// ─── Coin age weight monotonicity (P1 — staking economics) ──────────────────
|
|
// GetWeight must be non-decreasing in coin age (more age = at least as
|
|
// much weight, never less). A violation would let stakers game the
|
|
// system by waiting for specific age windows.
|
|
BOOST_AUTO_TEST_CASE(coin_age_weight_monotonic)
|
|
{
|
|
int64_t now = 1700000000;
|
|
int64_t prevWeight = 0;
|
|
// Sample at increasing ages, skipping the zero-weight region below
|
|
// nStakeMinAge.
|
|
for (int64_t age = nStakeMinAge; age < nStakeMinAge + 100000; age += 5000) {
|
|
int64_t weight = GetWeight(now - age, now);
|
|
BOOST_CHECK_GE(weight, prevWeight);
|
|
prevWeight = weight;
|
|
}
|
|
}
|
|
|
|
// ─── Stake age soft cap (P1 — V5 fork economic rule) ──────────────────────
|
|
// The V5 fork (FORK_HEIGHT_V5) replaced the hard nStakeMaxAge cap with a
|
|
// 7-day soft cap. The cap only applies to stakes AFTER the activation
|
|
// timestamp (1776000000 = 2026-04-12 13:20 UTC). This is a soft fork
|
|
// rule — historical blocks staked before activation are unaffected.
|
|
//
|
|
// We test it in a way that does NOT depend on pindexBest (which is a
|
|
// global state) by using a fixed "now" that's well past activation and
|
|
// a height that's pre-V5. Pre-V5 path is in src/kernel.cpp:25-53.
|
|
BOOST_AUTO_TEST_CASE(stake_age_soft_cap_does_not_apply_pre_v5)
|
|
{
|
|
int64_t now = 1777000000; // well past 1776000000 activation
|
|
// With pindexBest == nullptr, the pre-V5 path runs (line 52 in
|
|
// kernel.cpp): min(nAge, nStakeMaxAge). nStakeMaxAge is 12 hours.
|
|
int64_t veryOld = now - nStakeMinAge - (10 * 24 * 60 * 60); // 10 days old
|
|
int64_t weight = GetWeight(veryOld, now);
|
|
// Pre-V5 cap is nStakeMaxAge = 43200 (12 hours).
|
|
BOOST_CHECK_EQUAL(weight, (int64_t)nStakeMaxAge);
|
|
|
|
// Right at the cap boundary:
|
|
int64_t atMaxAge = now - nStakeMinAge - nStakeMaxAge;
|
|
BOOST_CHECK_EQUAL(GetWeight(atMaxAge, now), (int64_t)nStakeMaxAge);
|
|
// One second past: also capped.
|
|
int64_t justPastMax = now - nStakeMinAge - nStakeMaxAge - 1;
|
|
BOOST_CHECK_EQUAL(GetWeight(justPastMax, now), (int64_t)nStakeMaxAge);
|
|
}
|
|
|
|
// ─── PoS validation fast path must be height-based (P0) ───────────────────
|
|
// IsInitialBlockDownload() can also mean "tip is stale". That operational
|
|
// state must never disable proof-of-stake kernel/reward validation for new
|
|
// blocks above the hardened-checkpoint / rolling-assume-valid fast path.
|
|
BOOST_AUTO_TEST_CASE(pos_validation_skip_is_only_historical_fast_path)
|
|
{
|
|
int oldAssumeValid = nAssumeValidThreshold;
|
|
nAssumeValidThreshold = 0;
|
|
|
|
const int checkpointHeight = Checkpoints::GetTotalBlocksEstimate();
|
|
|
|
BOOST_CHECK(IsConsensusAssumeValidHeight(checkpointHeight));
|
|
BOOST_CHECK(!IsConsensusAssumeValidHeight(checkpointHeight + 1));
|
|
|
|
nAssumeValidThreshold = checkpointHeight + 25;
|
|
BOOST_CHECK(IsConsensusAssumeValidHeight(checkpointHeight + 25));
|
|
BOOST_CHECK(!IsConsensusAssumeValidHeight(checkpointHeight + 26));
|
|
|
|
nAssumeValidThreshold = oldAssumeValid;
|
|
}
|
|
|
|
// ─── Orphan block cap (P1 — DoS) ──────────────────────────────────────────
|
|
// The cap on stored orphan blocks prevents an attacker from filling
|
|
// memory with garbage. If too low, legitimate orphans are dropped. If
|
|
// too high, a DoS vector opens.
|
|
BOOST_AUTO_TEST_CASE(orphan_block_caps_reasonable)
|
|
{
|
|
BOOST_CHECK_GT(MAX_ORPHAN_BLOCKS, 0);
|
|
BOOST_CHECK_GT(MAX_ORPHAN_BLOCKS_IBD, MAX_ORPHAN_BLOCKS);
|
|
// IBD cap is typically ~2x normal to handle burst arrivals during
|
|
// initial sync.
|
|
BOOST_CHECK_LE(MAX_ORPHAN_BLOCKS_IBD, MAX_ORPHAN_BLOCKS * 4);
|
|
}
|
|
|
|
// ─── Fee constants (P2 — economic policy) ─────────────────────────────────
|
|
// Fees below MIN_TX_FEE must be rejected (DoS protection). MIN_RELAY_TX_FEE
|
|
// can be ≤ MIN_TX_FEE (relay tolerance is looser than mining tolerance).
|
|
BOOST_AUTO_TEST_CASE(fee_constants)
|
|
{
|
|
BOOST_CHECK_GT(MIN_TX_FEE, 0);
|
|
BOOST_CHECK_GT(MIN_RELAY_TX_FEE, 0);
|
|
BOOST_CHECK_LE(MIN_RELAY_TX_FEE, MIN_TX_FEE * 100); // sanity bound
|
|
BOOST_CHECK_EQUAL(MIN_TX_FEE, CENT / 100);
|
|
BOOST_CHECK_EQUAL(MIN_RELAY_TX_FEE, CENT / 100);
|
|
}
|
|
|
|
// ─── Block target spacing (P0) ────────────────────────────────────────────
|
|
// 120 seconds is the chain's identity. If it changes, every difficulty
|
|
// retarget computation will diverge → chain split.
|
|
BOOST_AUTO_TEST_CASE(target_spacing_immutable)
|
|
{
|
|
BOOST_CHECK_EQUAL(nTargetSpacing, 120u);
|
|
// 120s target = 2 min per block = 30 blocks/hour = 720 blocks/day
|
|
// = 262800 blocks/year (720 * 365).
|
|
int64_t blocksPerHour = 3600 / nTargetSpacing; // 3600s/hr / 120s/block
|
|
int64_t blocksPerDay = blocksPerHour * 24;
|
|
int64_t blocksPerYear = blocksPerDay * 365;
|
|
BOOST_CHECK_EQUAL(blocksPerHour, 30);
|
|
BOOST_CHECK_EQUAL(blocksPerDay, 720);
|
|
BOOST_CHECK_EQUAL(blocksPerYear, 262800);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_SUITE_END()
|