Implement genesis reconciliation protocol for sidechain stability

Problem: P2Pool nodes starting at different times or experiencing network
issues would create independent genesis blocks, resulting in incompatible
chains. Nodes would ban each other for invalid blocks that were actually
valid on a different chain. Cache resume after restart frequently failed
due to genesis mismatch between nodes.

Solution:  Oldest compatible genesis wins protocol that coordinates
genesis selection across peers before mining begins.

New P2P message GENESIS_INFO exchanges:
- Genesis block hash
- Genesis timestamp
- Genesis mainchain height
- Protocol version

Startup behavior:
- Wait up to 90 seconds for peer genesis info (with progress logging)
- Adopt oldest genesis from compatible peers
- Only create own genesis if no peers respond

Late joiner reconciliation:
- Running nodes that receive older genesis from new peer will purge
  their sidechain and re-sync to the older chain
- Cache files deleted on purge to prevent reload of stale blocks

Protocol versioning:
- PROTOCOL_VERSION constant at top of side_chain.h
- Increment only on consensus-breaking changes
- Version mismatch logs warning, prevents genesis adoption

Tiebreaker: When timestamps match, lexicographically lower hash wins.
This commit is contained in:
Matt Hess
2025-12-05 23:44:53 +00:00
parent a5ee896215
commit 00fb078004
18 changed files with 1011 additions and 279 deletions
+312 -76
View File
@@ -72,6 +72,7 @@ SideChain::SideChain(p2pool* pool, NetworkType type, const char* pool_name, cons
, m_chainWindowSize(2160)
, m_unclePenalty(20)
, m_precalcFinished(false)
, m_externalBlockFailures(0)
#ifdef DEV_TEST_SYNC
, m_firstPruneTime(0)
#endif
@@ -245,9 +246,47 @@ bool SideChain::fill_sidechain_data(PoolBlock& block, std::vector<MinerShare>& s
const PoolBlock* tip = m_chainTip;
if (!tip) {
if (!tip) {
// If we've learned of a peer's genesis, wait for their chain instead of creating our own
if (!m_adoptedGenesisId.empty()) {
const uint64_t elapsed = seconds_since_epoch() - m_adoptedGenesisTime;
const uint64_t timeout = 90;
if (elapsed < timeout) {
// Log every 15 seconds so user knows we're working
if ((elapsed % 15) < 2) {
LOGINFO(3, "Waiting for peer's genesis block " << m_adoptedGenesisId
<< " (" << elapsed << "s / " << timeout << "s)");
}
return false;
}
// Timeout - give up on peer's genesis and create our own
LOGWARN(3, "Timeout waiting for peer's genesis block after " << elapsed
<< "s, creating own genesis");
m_adoptedGenesisId = {};
m_adoptedGenesisTimestamp = 0;
m_adoptedGenesisHeight = 0;
m_adoptedGenesisTime = 0;
}
// Don't create genesis block until initial peer sync has been attempted
// This prevents nodes from creating independent chains when starting simultaneously
if (!m_precalcFinished.load()) {
P2PServer* p2p = m_pool->p2p_server();
if (p2p && (p2p->peer_list_size() > 0 || p2p->num_connections() > 0)) {
LOGINFO(5, "Waiting for initial peer sync before creating genesis block");
return false;
}
}
// No peers with existing chain - create our own genesis
LOGINFO(3, "Creating new genesis block (no peer chain found)");
m_genesisDecisionMade = true;
block.m_parent = {};
block.m_sidechainHeight = 0;
block.m_difficulty = m_minDifficulty;
block.m_cumulativeDifficulty = m_minDifficulty;
block.m_txkeySecSeed = m_consensusHash;
@@ -374,7 +413,7 @@ bool SideChain::get_shares(const PoolBlock* tip, std::vector<MinerShare>& shares
;
if (m_pool && !tip->m_parent.empty()) {
const uint64_t h = p2pool::get_seed_height(tip->m_txinGenHeight);
const uint64_t h = tip->m_txinGenHeight;
if (!m_pool->get_difficulty_at_height(h, mainchain_diff)) {
LOGWARN(L, "get_shares: couldn't get mainchain difficulty for height = " << h);
return false;
@@ -433,6 +472,8 @@ bool SideChain::get_shares(const PoolBlock* tip, std::vector<MinerShare>& shares
}
pplns_weight += cur_weight;
LOGINFO(0, "get_shares: height=" << cur->m_sidechainHeight << " wallet=" << cur->m_minerWallet << " pplns_weight=" << pplns_weight << " max=" << max_pplns_weight << " mainchain_h=" << tip->m_txinGenHeight << " depth=" << block_depth);
// One non-uncle share can go above the limit, but it will also guarantee that "shares" is never empty
if (pplns_weight > max_pplns_weight) {
break;
@@ -601,7 +642,7 @@ bool SideChain::add_external_block(PoolBlock& block, std::vector<hash>& missing_
if (!m_pool->get_difficulty_at_height(block.m_txinGenHeight, diff)) {
LOGWARN(3, "add_external_block: couldn't get mainchain difficulty for height = " << block.m_txinGenHeight);
}
else if (diff.check_pow(block.m_powHash)) {
else if (diff.check_pow(block.m_powHash) && (block.m_txinGenHeight + 2 >= miner_data.height)) {
LOGINFO(0, log::LightGreen() << "add_external_block: block " << block.m_sidechainId << " has enough PoW for Salvium height " << block.m_txinGenHeight << ", submitting it");
m_pool->submit_block_async(block.serialize_mainchain_data());
}
@@ -679,7 +720,30 @@ bool SideChain::add_external_block(PoolBlock& block, std::vector<hash>& missing_
m_pool->api_update_block_found(&data, &block);
}
return add_block(block);
const bool added = add_block(block);
if (added && block.m_verified) {
if (block.m_invalid) {
++m_externalBlockFailures;
LOGWARN(3, "external block validation failed, failure count: " << m_externalBlockFailures << "/" << DIVERGENCE_THRESHOLD);
if (m_externalBlockFailures >= DIVERGENCE_THRESHOLD) {
LOGERR(0, log::LightRed() << "DIVERGENCE DETECTED: " << m_externalBlockFailures << " consecutive external block failures. Purging cache and restarting...");
// Delete cache file
const std::string cache_path = "p2pool.cache";
remove(cache_path.c_str());
// Signal shutdown - systemd will restart
if (m_pool) {
m_pool->stop();
}
}
} else {
// Valid external block - reset counter
if (m_externalBlockFailures > 0) {
LOGINFO(3, "external block validated successfully, resetting failure counter from " << m_externalBlockFailures);
m_externalBlockFailures = 0;
}
}
}
return added;
}
bool SideChain::add_block(const PoolBlock& block)
@@ -809,6 +873,16 @@ const PoolBlock* SideChain::get_block_blob(const hash& id, std::vector<uint8_t>&
}
blob = block->serialize_mainchain_data();
{
std::string hex;
size_t start = 43; // approximate outputs offset
for (size_t i = start; i < std::min<size_t>(start + 64, blob.size()); ++i) {
char buf[4];
snprintf(buf, sizeof(buf), "%02x", blob[i]);
hex += buf;
}
LOGINFO(0, "DEBUG get_block_blob outputs area (64 bytes from offset 43): " << hex);
}
const std::vector<uint8_t> sidechain_data = block->serialize_sidechain_data();
blob.insert(blob.end(), sidechain_data.begin(), sidechain_data.end());
@@ -837,28 +911,75 @@ bool SideChain::get_outputs_blob(PoolBlock* block, uint64_t total_reward, std::v
ReadLock lock(m_sidechainLock);
auto it = block->m_sidechainId.empty() ? m_blocksById.end() : m_blocksById.find(block->m_sidechainId);
if (it != m_blocksById.end()) {
const PoolBlock* b = it->second;
const size_t n = b->m_outputAmounts.size();
if (it != m_blocksById.end()) {
const PoolBlock* b = it->second;
const size_t n = b->m_outputAmounts.size();
blob.reserve(n * 58 + 64);
writeVarint(n, blob);
for (size_t i = 0; i < n; ++i) {
const PoolBlock::TxOutput& output = b->m_outputAmounts[i];
writeVarint(output.m_reward, blob);
blob.emplace_back(TXOUT_TO_CARROT_V1);
const hash h = b->m_ephPublicKeys[i];
blob.insert(blob.end(), h.h, h.h + HASH_SIZE);
if (b->m_majorVersion >= 10) {
// Carrot v1 format
blob.push_back(4);
blob.push_back('S');
blob.push_back('A');
blob.push_back('L');
blob.push_back('1');
blob.insert(blob.end(), b->m_viewTags[i].begin(), b->m_viewTags[i].end());
blob.insert(blob.end(), b->m_encryptedAnchors[i].begin(), b->m_encryptedAnchors[i].end());
} else {
blob.emplace_back(static_cast<uint8_t>(output.m_viewTag));
}
}
block->m_ephPublicKeys = b->m_ephPublicKeys;
block->m_outputAmounts = b->m_outputAmounts;
block->m_viewTags = b->m_viewTags;
block->m_encryptedAnchors = b->m_encryptedAnchors;
return true;
}
blob.reserve(n * 39 + 64);
writeVarint(n, blob);
// For Carrot v1+ external blocks, K_o values are NOT derivable from m_txkeySec
// They must be preserved from parsing (computed via Carrot crypto)
if (block->m_majorVersion >= 10 && !block->m_ephPublicKeys.empty()) {
const size_t n = block->m_ephPublicKeys.size();
blob.reserve(n * 58 + 64);
writeVarint(n, blob);
for (size_t i = 0; i < n; ++i) {
const PoolBlock::TxOutput& output = block->m_outputAmounts[i];
writeVarint(output.m_reward, blob);
blob.emplace_back(TXOUT_TO_CARROT_V1);
blob.insert(blob.end(), block->m_ephPublicKeys[i].h, block->m_ephPublicKeys[i].h + HASH_SIZE);
// Carrot v1 format
blob.push_back(4);
blob.push_back('S');
blob.push_back('A');
blob.push_back('L');
blob.push_back('1');
blob.insert(blob.end(), block->m_viewTags[i].begin(), block->m_viewTags[i].end());
blob.insert(blob.end(), block->m_encryptedAnchors[i].begin(), block->m_encryptedAnchors[i].end());
}
return true;
}
for (size_t i = 0; i < n; ++i) {
const PoolBlock::TxOutput& output = b->m_outputAmounts[i];
writeVarint(output.m_reward, blob);
blob.emplace_back(TXOUT_TO_TAGGED_KEY);
const hash h = b->m_ephPublicKeys[i];
blob.insert(blob.end(), h.h, h.h + HASH_SIZE);
blob.emplace_back(static_cast<uint8_t>(output.m_viewTag));
}
// Can't compute outputs from PPLNS if we don't have this block's parent chain
if (!block->m_parent.empty()) {
auto parent_it = m_blocksById.find(block->m_parent);
if (parent_it == m_blocksById.end()) {
LOGINFO(5, "get_outputs_blob: can't compute outputs, parent " << block->m_parent << " not found");
return false;
}
}
block->m_ephPublicKeys = b->m_ephPublicKeys;
block->m_outputAmounts = b->m_outputAmounts;
return true;
}
data = std::make_shared<Data>();
data = std::make_shared<Data>();
data->blockMinerWallet = block->m_minerWallet;
data->txkeySec = block->m_txkeySec;
@@ -866,8 +987,8 @@ bool SideChain::get_outputs_blob(PoolBlock* block, uint64_t total_reward, std::v
return false;
}
// Handle donation mode during validation - match block creation logic
const uint64_t block_height = block->m_sidechainHeight;
if ((block_height % 100) == 0 && m_devWallet) {
const uint64_t donation_cycle = (s_networkType == NetworkType::Mainnet) ? DONATION_CYCLE_MAINNET : DONATION_CYCLE_TESTNET;
if ((block->m_txinGenHeight % donation_cycle) == 0 && m_devWallet) {
// This is a donation block - replace all shares with single dev wallet output
difficulty_type total_weight;
for (const auto& share : data->tmpShares) {
@@ -919,7 +1040,8 @@ bool SideChain::get_outputs_blob(PoolBlock* block, uint64_t total_reward, std::v
block->m_ephPublicKeys.clear();
block->m_outputAmounts.clear();
block->m_viewTags.clear();
block->m_encryptedAnchors.clear();
block->m_ephPublicKeys.reserve(n);
block->m_outputAmounts.reserve(n);
@@ -934,7 +1056,7 @@ bool SideChain::get_outputs_blob(PoolBlock* block, uint64_t total_reward, std::v
writeVarint(tmpRewards[i], blob);
blob.emplace_back(TXOUT_TO_TAGGED_KEY);
blob.emplace_back(TXOUT_TO_CARROT_V1);
uint8_t view_tag;
if (!data->tmpShares[i].m_wallet->get_eph_public_key(data->txkeySec, i, eph_public_key, view_tag)) {
@@ -1531,12 +1653,17 @@ void SideChain::verify(PoolBlock* block)
!block->m_uncles.empty() ||
(block->m_difficulty != m_minDifficulty) ||
(block->m_cumulativeDifficulty != m_minDifficulty) ||
(block->m_txkeySecSeed != m_consensusHash))
(block->m_txkeySecSeed != m_consensusHash))
{
LOGWARN(3, "genesis block validation failed: height=" << block->m_sidechainHeight
<< " parent_empty=" << (block->m_parent.empty() ? 1 : 0)
<< " diff=" << block->m_difficulty << " expected=" << m_minDifficulty);
block->m_invalid = true;
}
block->m_verified = true;
if (!block->m_invalid) {
LOGINFO(3, "Genesis block verified: " << block->m_sidechainId);
}
return;
}
@@ -1556,7 +1683,8 @@ void SideChain::verify(PoolBlock* block)
// Regular block
// Must have a parent
if (block->m_parent.empty()) {
if (block->m_parent.empty()) {
LOGWARN(3, "block at height = " << block->m_sidechainHeight << ", id = " << block->m_sidechainId << ", mainchain height = " << block->m_txinGenHeight << " has empty parent (non-genesis)");
block->m_verified = true;
block->m_invalid = true;
return;
@@ -1571,7 +1699,8 @@ void SideChain::verify(PoolBlock* block)
// If it's invalid then this block is also invalid
const PoolBlock* parent = it->second;
if (parent->m_invalid) {
if (parent->m_invalid) {
LOGWARN(3, "block at height = " << block->m_sidechainHeight << ", id = " << block->m_sidechainId << ", mainchain height = " << block->m_txinGenHeight << ": get_shares failed");
block->m_verified = true;
block->m_invalid = true;
return;
@@ -1580,7 +1709,7 @@ void SideChain::verify(PoolBlock* block)
// Check m_txkeySecSeed
const hash h = (block->m_prevId == parent->m_prevId) ? parent->m_txkeySecSeed : parent->calculate_tx_key_seed();
if (block->m_txkeySecSeed != h) {
LOGWARN(3, "block " << block->m_sidechainId << " has invalid tx key seed: expected " << h << ", got " << block->m_txkeySecSeed);
LOGWARN(3, "block at height = " << block->m_sidechainHeight << ", id = " << block->m_sidechainId << ", mainchain height = " << block->m_txinGenHeight << " has invalid parent " << block->m_parent);
block->m_verified = true;
block->m_invalid = true;
return;
@@ -1746,15 +1875,13 @@ void SideChain::verify(PoolBlock* block)
diff = difficulty();
}
else if (!get_difficulty(parent, m_difficultyData, diff)) {
LOGWARN(3, "block at height = " << block->m_sidechainHeight << ", id = " << block->m_sidechainId << ", mainchain height = " << block->m_txinGenHeight << ": get_difficulty failed for parent " << block->m_parent);
block->m_invalid = true;
return;
}
if (diff != block->m_difficulty) {
LOGWARN(3, "block at height = " << block->m_sidechainHeight <<
", id = " << block->m_sidechainId <<
", mainchain height = " << block->m_txinGenHeight <<
" has wrong difficulty: got " << block->m_difficulty << ", expected " << diff);
LOGWARN(3, "block at height = " << block->m_sidechainHeight << ", id = " << block->m_sidechainId << ", mainchain height = " << block->m_txinGenHeight << " has wrong difficulty: got " << block->m_difficulty << ", expected " << diff);
block->m_invalid = true;
return;
}
@@ -1766,13 +1893,15 @@ void SideChain::verify(PoolBlock* block)
shares = std::move(block->m_precalculatedShares);
}
if (shares.empty() && !get_shares(block, shares)) {
if (shares.empty() && !get_shares(block, shares)) {
LOGWARN(3, "block at height = " << block->m_sidechainHeight << ", id = " << block->m_sidechainId << ", mainchain height = " << block->m_txinGenHeight << ": get_shares failed");
block->m_invalid = true;
return;
}
// Handle donation mode during verification - match block creation logic
if ((block->m_sidechainHeight % 100) == 0 && m_devWallet) {
const uint64_t donation_cycle = (s_networkType == NetworkType::Mainnet) ? DONATION_CYCLE_MAINNET : DONATION_CYCLE_TESTNET;
if ((block->m_txinGenHeight % donation_cycle) == 0 && m_devWallet) {
difficulty_type total_weight;
for (const auto& share : shares) {
total_weight += share.m_weight;
@@ -1814,50 +1943,58 @@ void SideChain::verify(PoolBlock* block)
return;
}
for (size_t i = 0, n = rewards.size(); i < n; ++i) {
const PoolBlock::TxOutput& out = block->m_outputAmounts[i];
// Carrot v1 validation - generate and sort expected outputs
struct ValidationOutput {
size_t share_index;
uint64_t reward;
hash ko;
uint8_t view_tag;
};
std::vector<ValidationOutput> expected;
expected.reserve(rewards.size());
if (rewards[i] != out.m_reward) {
LOGWARN(3, "block at height = " << block->m_sidechainHeight <<
", id = " << block->m_sidechainId <<
", mainchain height = " << block->m_txinGenHeight <<
" has invalid reward at index " << i << ": got " << out.m_reward << ", expected " << rewards[i]);
block->m_invalid = true;
return;
}
for (size_t i = 0; i < rewards.size(); ++i) {
ValidationOutput out;
out.share_index = i;
out.reward = rewards[i];
if (!shares[i].m_wallet->get_eph_public_key_carrot(block->m_txkeySecSeed, block->m_txinGenHeight, i, rewards[i], out.ko, out.view_tag)) {
LOGWARN(3, "block at height " << block->m_sidechainHeight << " failed to generate K_o at index " << i);
block->m_invalid = true;
return;
}
expected.push_back(out);
}
hash eph_public_key;
uint8_t view_tag;
if (!shares[i].m_wallet->get_eph_public_key(block->m_txkeySec, i, eph_public_key, view_tag)) {
LOGWARN(3, "block at height = " << block->m_sidechainHeight <<
", id = " << block->m_sidechainId <<
", mainchain height = " << block->m_txinGenHeight <<
" failed to eph_public_key at index " << i);
block->m_invalid = true;
return;
}
// Sort by K_o
std::sort(expected.begin(), expected.end(),
[](const ValidationOutput& a, const ValidationOutput& b) {
return memcmp(a.ko.h, b.ko.h, HASH_SIZE) < 0;
});
if (out.m_viewTag != view_tag) {
LOGWARN(3, "block at height = " << block->m_sidechainHeight <<
", id = " << block->m_sidechainId <<
", mainchain height = " << block->m_txinGenHeight <<
" has an incorrect view tag at index " << i);
block->m_invalid = true;
return;
}
// Validate sorted outputs
for (size_t i = 0; i < expected.size(); ++i) {
const ValidationOutput& exp = expected[i];
const PoolBlock::TxOutput& actual = block->m_outputAmounts[i];
if (eph_public_key != block->m_ephPublicKeys[i]) {
LOGWARN(3, "block at height = " << block->m_sidechainHeight <<
", id = " << block->m_sidechainId <<
", mainchain height = " << block->m_txinGenHeight <<
" pays out to a wrong wallet at index " << i);
block->m_invalid = true;
return;
}
}
if (exp.reward != actual.m_reward) {
LOGWARN(3, "block at height " << block->m_sidechainHeight << " has invalid reward at position " << i);
block->m_invalid = true;
return;
}
if (exp.view_tag != actual.m_viewTag) {
LOGWARN(3, "block at height " << block->m_sidechainHeight << " has invalid view_tag at position " << i);
block->m_invalid = true;
return;
}
if (exp.ko != block->m_ephPublicKeys[i]) {
LOGWARN(3, "block at height " << block->m_sidechainHeight << " has invalid K_o at position " << i);
block->m_invalid = true;
return;
}
}
// All checks passed
block->m_invalid = false;
// All checks passed
block->m_invalid = false;
}
void SideChain::update_chain_tip(PoolBlock* block)
@@ -2340,6 +2477,105 @@ void SideChain::get_missing_blocks(unordered_set<hash>& missing_blocks) const
}
}
bool SideChain::consider_peer_genesis(const hash& genesis_id, uint64_t timestamp, uint64_t height)
{
// Get our current genesis info for comparison
uint64_t our_genesis_timestamp = 0;
hash our_genesis_id;
{
ReadLock lock(m_sidechainLock);
auto it = m_blocksByHeight.find(0);
if (it != m_blocksByHeight.end() && !it->second.empty()) {
our_genesis_timestamp = it->second.front()->m_timestamp;
our_genesis_id = it->second.front()->m_sidechainId;
}
}
// If we have a genesis and peer's is older (or same timestamp but lower hash), we need to yield
if (m_genesisDecisionMade && our_genesis_timestamp > 0) {
const bool peer_wins = (timestamp < our_genesis_timestamp) ||
(timestamp == our_genesis_timestamp && genesis_id < our_genesis_id);
if (peer_wins) {
LOGWARN(3, "Peer has older genesis (theirs=" << timestamp
<< " ours=" << our_genesis_timestamp << "), purging to re-sync");
purge_sidechain();
// Fall through to adopt peer's genesis
} else {
// Our genesis is older or same, keep it
return true;
}
}
if (m_adoptedGenesisTimestamp == 0 || timestamp < m_adoptedGenesisTimestamp ||
(timestamp == m_adoptedGenesisTimestamp && genesis_id < m_adoptedGenesisId)) {
LOGINFO(3, "Adopting older genesis from peer: " << genesis_id
<< " timestamp=" << timestamp << " height=" << height);
m_adoptedGenesisId = genesis_id;
m_adoptedGenesisTimestamp = timestamp;
m_adoptedGenesisHeight = height;
m_adoptedGenesisTime = seconds_since_epoch();
}
return true;
}
void SideChain::purge_sidechain()
{
LOGWARN(3, "Purging sidechain to adopt older genesis from peer");
WriteLock lock(m_sidechainLock);
// Free all block memory
for (auto& it : m_blocksById) {
delete it.second;
}
// Clear all block tracking
m_blocksById.clear();
m_blocksByHeight.clear();
m_blocksByMerkleRoot.clear();
m_chainTip = nullptr;
// Clear difficulty data
m_difficultyData.clear();
// Reset genesis state to allow new genesis adoption
m_genesisDecisionMade = false;
m_adoptedGenesisId = {};
m_adoptedGenesisTimestamp = 0;
m_adoptedGenesisHeight = 0;
m_adoptedGenesisTime = 0;
// Delete cache files to prevent reload of old blocks on restart
const std::string cache_path = DATA_DIR + "p2pool.cache";
const std::string version_path = DATA_DIR + "p2pool.cache.version";
if (remove(cache_path.c_str()) == 0) {
LOGINFO(3, "Deleted cache file: " << cache_path);
}
if (remove(version_path.c_str()) == 0) {
LOGINFO(3, "Deleted cache version file: " << version_path);
}
LOGINFO(3, "Sidechain purged, ready to sync from peer");
}
bool SideChain::get_genesis_info(hash& id, uint64_t& timestamp, uint64_t& height) const
{
ReadLock lock(m_sidechainLock);
auto it = m_blocksByHeight.find(0);
if (it == m_blocksByHeight.end() || it->second.empty()) {
return false;
}
const PoolBlock* genesis = it->second.front();
id = genesis->m_sidechainId;
timestamp = genesis->m_timestamp;
height = genesis->m_txinGenHeight;
return true;
}
bool SideChain::load_config(const std::string& filename)
{
if (filename.empty()) {