Skip to content

Commit a154aee

Browse files
AIQnetLabclaude
andcommitted
rewards O(1) macroblock + cold-start sync + rolling-upgrade mechanism
rewards: - A: single watermarked emission mint, fixes per-node total_supply divergence - B: per-epoch merkle root + lazy proof-claim, no eager O(N) accrual / O(N) emission-TX map - O(1) macroblock: drop reward_heartbeats/reward_light_nodes; apply recomputes super (registry + heartbeat popcount>=9) and light (deterministic roster + on-chain bitmaps) - reg_height: deterministic apply-only registry index (super_registrations_sorted / light_roster_sorted), replaces the non-deterministic RAM registry - NodeActivation registers the canonical super pseudonym so a node is reward-resolvable without the separate NodeRegistration TX cold-start sync: - verify-before-commit snapshot: discard state on every reject path (no orphaned state) - serve macroblocks from any synced super-node, not genesis-only - no peer strike on local state_root_mismatch; quorum (>=2-peer) snapshot height drift: - C: block_ts forward-projected median cap -> drift ~0 (deterministic gen+validation) rolling upgrades: - feature_gates::is_active(feature, height): height-coordinated consensus-rule activation - wire protocol-version range tolerance [MIN_SUPPORTED..=CURRENT] (was hard-reject) - downgrade-safe rocksdb open (list_cf union with known CFs) Validated: qnet-state 58 + qnet-integration 160 lib tests, 0 regressions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2521ad6 commit a154aee

14 files changed

Lines changed: 991 additions & 438 deletions

File tree

core/qnet-state/src/account.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ pub struct Account {
130130
/// popcount of `heartbeat_slots` for `heartbeat_final_epoch` (the finalized liveness count).
131131
#[serde(default)]
132132
pub heartbeat_final_count: u8,
133+
134+
/// Highest reward epoch this account has already claimed (merkle-claim anti-replay).
135+
/// A claim TX is valid only for an epoch strictly greater than this and advances it on
136+
/// success. Part of the leaf hash (consensus-bound — see hash_account).
137+
#[serde(default)]
138+
pub last_claimed_epoch: u64,
133139
}
134140

135141
/// Account state (alias for compatibility)
@@ -188,6 +194,7 @@ impl Default for AccountState {
188194
heartbeat_slots: 0,
189195
heartbeat_final_epoch: 0,
190196
heartbeat_final_count: 0,
197+
last_claimed_epoch: 0,
191198
}
192199
}
193200
}
@@ -262,6 +269,7 @@ impl Account {
262269
heartbeat_slots: 0,
263270
heartbeat_final_epoch: 0,
264271
heartbeat_final_count: 0,
272+
last_claimed_epoch: 0,
265273
}
266274
}
267275

@@ -287,6 +295,7 @@ impl Account {
287295
heartbeat_slots: 0,
288296
heartbeat_final_epoch: 0,
289297
heartbeat_final_count: 0,
298+
last_claimed_epoch: 0,
290299
}
291300
}
292301

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//! Consensus feature gates — coordinated activation heights for protocol-rule changes.
2+
//!
3+
//! A consensus-rule change that is "born active" diverges the instant one node runs it while peers
4+
//! still run the old rule — the cause of the rolling-upgrade halt. Binding the change to an
5+
//! activation HEIGHT lets operators roll out a new binary node-by-node: the new rule stays dormant
6+
//! until `height`, then EVERY node switches at the same height — no cross-version divergence.
7+
//!
8+
//! To ship a rolling-safe consensus change:
9+
//! 1. add `("feature_id", activation_height)` to `ACTIVATIONS` (a coordinated FUTURE height);
10+
//! 2. gate the divergent code: `if feature_gates::is_active("feature_id", height) { new } else { old }`;
11+
//! 3. deploy the binary to all nodes BEFORE `activation_height`.
12+
//! Genesis-active rules need no entry — the default is active.
13+
14+
/// (feature id, activation height). Empty on this chain — all current rules are genesis-active.
15+
/// Heights are hardcoded in the binary, so every node agrees without on-chain governance.
16+
const ACTIVATIONS: &[(&str, u64)] = &[];
17+
18+
/// Core gate: active iff `feature` is unlisted (genesis-active default) or `height` has reached
19+
/// its scheduled activation. Pure ⇒ identical on every node at the same height.
20+
fn is_active_at(activations: &[(&str, u64)], feature: &str, height: u64) -> bool {
21+
match activations.iter().find(|(f, _)| *f == feature) {
22+
Some((_, activation_height)) => height >= *activation_height,
23+
None => true,
24+
}
25+
}
26+
27+
/// True iff the consensus `feature` is active at `height` (see module docs).
28+
pub fn is_active(feature: &str, height: u64) -> bool {
29+
is_active_at(ACTIVATIONS, feature, height)
30+
}
31+
32+
#[cfg(test)]
33+
mod tests {
34+
use super::is_active_at;
35+
36+
#[test]
37+
fn gate_switches_at_activation_height() {
38+
let reg = &[("feat_x", 1000u64)][..];
39+
assert!(!is_active_at(reg, "feat_x", 999), "dormant before activation height");
40+
assert!(is_active_at(reg, "feat_x", 1000), "active exactly at activation height");
41+
assert!(is_active_at(reg, "feat_x", 5000), "active after activation height");
42+
}
43+
44+
#[test]
45+
fn unlisted_feature_is_genesis_active() {
46+
let reg = &[("feat_x", 1000u64)][..];
47+
assert!(is_active_at(reg, "other", 0), "unlisted feature active from genesis");
48+
}
49+
50+
#[test]
51+
fn production_registry_all_active() {
52+
// No scheduled features on this chain ⇒ every gate active from height 0.
53+
assert!(super::is_active("any_current_rule", 0));
54+
}
55+
}

core/qnet-state/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub mod state_db;
1515
pub mod state_manager;
1616
pub mod errors;
1717
pub mod state;
18+
pub mod feature_gates;
1819

1920
#[cfg(feature = "python")]
2021
mod python_bindings;

core/qnet-state/src/state.rs

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,11 @@ impl StateMerkleTree {
419419
hasher.update(&account.heartbeat_slots.to_le_bytes());
420420
hasher.update(&account.heartbeat_final_epoch.to_le_bytes());
421421
hasher.update(&[account.heartbeat_final_count]);
422+
// last_claimed_epoch: reward-claim watermark — ALWAYS in leaf hash (fixed schema,
423+
// same rule as pending_rewards/HB). Anti-replay for merkle claims must be
424+
// consensus-bound, else nodes diverge on which epochs an account already claimed.
425+
hasher.update(b"LCE:");
426+
hasher.update(&account.last_claimed_epoch.to_le_bytes());
422427
// EXCLUDED from hash (non-deterministic or metadata-only):
423428
// - reputation: f64 is non-deterministic across platforms
424429
// - is_node, node_type, created_at, updated_at: metadata only
@@ -638,6 +643,9 @@ pub struct ChainState {
638643
pub height: u64,
639644
/// Total supply in nanoQNC (smallest units: 1 QNC = 10^9 nanoQNC)
640645
pub total_supply: u64,
646+
/// Highest emission macroblock already minted into total_supply.
647+
/// Monotonic watermark — makes emission idempotent across re-apply/sync.
648+
pub last_minted_emission_mb: u64,
641649

642650
/// Current epoch
643651
pub epoch: u64,
@@ -650,6 +658,7 @@ impl Default for ChainState {
650658
Self {
651659
height: 0,
652660
total_supply: 0, // FAIR LAUNCH: starts at 0, increases only through Pool 1 Base Emission
661+
last_minted_emission_mb: 0,
653662

654663
epoch: 0,
655664
last_finalized: 0,
@@ -1306,6 +1315,7 @@ impl StateManager {
13061315
heartbeat_slots: 0,
13071316
heartbeat_final_epoch: 0,
13081317
heartbeat_final_count: 0,
1318+
last_claimed_epoch: 0,
13091319
};
13101320

13111321
StateMerkleTree::verify_proof(
@@ -1930,7 +1940,33 @@ impl StateManager {
19301940

19311941
Ok(())
19321942
}
1933-
1943+
1944+
/// v3 merkle-claim credit: credit a proof-verified reward into the wallet's balance and
1945+
/// advance the per-account claim watermark. Anti-replay: returns false (no-op) if the
1946+
/// account already claimed this epoch or a later one. The merkle proof itself is verified
1947+
/// by the caller (node.rs apply, which holds the epoch root); this enforces the watermark
1948+
/// and applies the balance credit + Merkle update atomically under the state lock.
1949+
pub fn claim_reward(&self, wallet: &str, epoch: u64, amount: u64) -> bool {
1950+
let mut account = self.accounts.entry(wallet.to_string())
1951+
.or_insert_with(|| Account::new(wallet.to_string()));
1952+
if epoch <= account.last_claimed_epoch {
1953+
return false;
1954+
}
1955+
account.balance = account.balance.saturating_add(amount);
1956+
account.last_claimed_epoch = epoch;
1957+
{
1958+
let mut tree = self.merkle_tree.write();
1959+
tree.insert_lazy(wallet, &account);
1960+
}
1961+
true
1962+
}
1963+
1964+
/// Highest reward epoch this account has already claimed (0 if never claimed).
1965+
/// The RPC uses it to find the next unclaimed epoch to build a merkle claim for.
1966+
pub fn get_last_claimed_epoch(&self, wallet: &str) -> u64 {
1967+
self.accounts.get(wallet).map(|a| a.last_claimed_epoch).unwrap_or(0)
1968+
}
1969+
19341970
/// v2.96: Get pending rewards for an account
19351971
pub fn get_pending_rewards(&self, wallet: &str) -> u64 {
19361972
self.accounts.get(wallet)
@@ -2028,20 +2064,30 @@ impl StateManager {
20282064
/// Emit rewards with MAX_SUPPLY control
20292065
/// amount: emission amount in nanoQNC (smallest units)
20302066
/// Returns: actual emitted amount in nanoQNC (may be less if MAX_SUPPLY reached)
2031-
pub fn emit_rewards(&self, amount: u64) -> StateResult<u64> {
2067+
/// Idempotent: mints only when `emission_mb` exceeds the watermark, so re-apply,
2068+
/// bulk-sync, or any redundant call path can never double- or under-count supply.
2069+
pub fn emit_rewards(&self, amount: u64, emission_mb: u64) -> StateResult<u64> {
20322070
let mut chain_state = self.chain_state.write();
2033-
2071+
2072+
// Watermark: each emission macroblock mints exactly once, deterministically.
2073+
if emission_mb > 0 && emission_mb <= chain_state.last_minted_emission_mb {
2074+
return Ok(0);
2075+
}
2076+
20342077
// Check if we would exceed MAX_SUPPLY (all in nanoQNC)
20352078
let remaining_supply = MAX_QNC_SUPPLY_NANO.saturating_sub(chain_state.total_supply);
20362079
let actual_emission = amount.min(remaining_supply);
2037-
2080+
20382081
if actual_emission == 0 {
20392082
println!("⚠️ MAX_SUPPLY reached: {} QNC. No more emissions possible!", MAX_QNC_SUPPLY);
20402083
return Ok(0);
20412084
}
2042-
2085+
20432086
// Update total supply (in nanoQNC)
20442087
chain_state.total_supply += actual_emission;
2088+
if emission_mb > 0 {
2089+
chain_state.last_minted_emission_mb = emission_mb;
2090+
}
20452091

20462092
if actual_emission < amount {
20472093
println!("⚠️ Emission limited: requested {} QNC, emitted {} QNC (remaining: {} QNC)",
@@ -2073,6 +2119,7 @@ impl StateManager {
20732119
let mut chain_state = self.chain_state.write();
20742120
chain_state.height = 0;
20752121
chain_state.total_supply = 0; // NO PREMINE - starts at 0!
2122+
chain_state.last_minted_emission_mb = 0; // reset watermark with supply
20762123
chain_state.epoch = 0;
20772124
chain_state.last_finalized = 0;
20782125
}
@@ -2205,6 +2252,27 @@ mod cache_tests {
22052252
a
22062253
}
22072254

2255+
/// B cutover anti-replay: claim_reward credits once per epoch and is monotonic.
2256+
/// last_claimed_epoch is consensus-bound (SMT leaf) so this property holds network-wide.
2257+
#[test]
2258+
fn claim_reward_is_replay_and_monotonic_safe() {
2259+
let sm = StateManager::new();
2260+
// First claim for epoch 5 credits and sets the watermark.
2261+
assert!(sm.claim_reward("w", 5, 100), "first claim must credit");
2262+
assert_eq!(sm.accounts.get("w").unwrap().balance, 100);
2263+
assert_eq!(sm.accounts.get("w").unwrap().last_claimed_epoch, 5);
2264+
// Replaying the SAME epoch must be a no-op (no double credit).
2265+
assert!(!sm.claim_reward("w", 5, 100), "replaying the same epoch must not credit");
2266+
assert_eq!(sm.accounts.get("w").unwrap().balance, 100);
2267+
// An OLDER epoch must be rejected (watermark is monotonic).
2268+
assert!(!sm.claim_reward("w", 4, 100), "older epoch must not credit");
2269+
assert_eq!(sm.accounts.get("w").unwrap().balance, 100);
2270+
// A NEWER epoch credits and advances the watermark.
2271+
assert!(sm.claim_reward("w", 6, 50), "newer epoch must credit");
2272+
assert_eq!(sm.accounts.get("w").unwrap().balance, 150);
2273+
assert_eq!(sm.accounts.get("w").unwrap().last_claimed_epoch, 6);
2274+
}
2275+
22082276
#[test]
22092277
fn test_warm_account_cache_hit() {
22102278
let sm = StateManager::new();

core/qnet-state/src/state_db.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ impl StateDB {
8787
heartbeat_slots: 0,
8888
heartbeat_final_epoch: 0,
8989
heartbeat_final_count: 0,
90+
last_claimed_epoch: 0,
9091
}
9192
});
9293

@@ -155,6 +156,7 @@ impl StateDB {
155156
heartbeat_slots: 0,
156157
heartbeat_final_epoch: 0,
157158
heartbeat_final_count: 0,
159+
last_claimed_epoch: 0,
158160
}
159161
});
160162

core/qnet-state/src/transaction.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2444,9 +2444,19 @@ impl Transaction {
24442444
// v2.96: CLAIM TX - validate and process reward claim
24452445
// This happens when user calls /api/v1/claim_rewards
24462446
if let Some(to) = &self.to {
2447+
// v3 merkle-claim: the proofs were verified and balances credited in node.rs
2448+
// apply Phase 2b (which has storage→epoch root). Skip the legacy pending_rewards
2449+
// debit so a merkle claim is not double-applied here.
2450+
if let Some(ref d) = self.data {
2451+
if let Ok(p) = serde_json::from_str::<serde_json::Value>(d) {
2452+
if p.get("claims").is_some() {
2453+
return Ok(());
2454+
}
2455+
}
2456+
}
24472457
let recipient = accounts.entry(to.clone())
24482458
.or_insert_with(|| Account::new(to.clone()));
2449-
2459+
24502460
// v2.96: SECURITY - Check if recipient has sufficient pending rewards
24512461
if self.amount > recipient.pending_rewards {
24522462
return Err(StateError::InvalidTransaction(

development/qnet-integration/src/block_pipeline.rs

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2483,16 +2483,11 @@ impl BlockPipeline {
24832483
metrics.apply_failed.fetch_add(1, Ordering::Relaxed);
24842484
crate::unified_p2p::clear_block_pending_sync(height);
24852485

2486-
// v14.8: Per-peer local quarantine — repeated state_root
2487-
// mismatches from the same peer signal either (a) the
2488-
// peer is on a different fork, or (b) the peer is
2489-
// actively hostile. Either way, stop wasting apply
2490-
// cycles on them for a cooldown window. This is a
2491-
// LOCAL defense; on-chain slashing still happens via
2492-
// the macroblock analyze_chain_for_slashing path.
2493-
if let Some(ref p2p) = ctx.unified_p2p {
2494-
p2p.record_apply_strike(&block.from_peer, "state_root_mismatch");
2495-
}
2486+
// Do NOT strike the peer here: the block already passed signature/hash
2487+
// validation before apply, so a state_root_mismatch is a LOCAL-state defect
2488+
// (e.g. a contaminated/orphaned base), not the peer's fault. Striking honest
2489+
// peers poisoned the pool and blocked cold-start recovery. Genuine forks are
2490+
// resolved by fork-choice; malice by on-chain analyze_chain_for_slashing.
24962491
metrics.mark_apply_idle();
24972492
continue;
24982493
}
@@ -2675,7 +2670,7 @@ impl BlockPipeline {
26752670
}
26762671
}
26772672
for (node_id, type_str, wallet) in &apply_result.deferred_registrations {
2678-
let _ = ctx.storage.save_node_registration(node_id, type_str, wallet, 1.0);
2673+
let _ = ctx.storage.save_node_registration_at_height(node_id, type_str, wallet, 1.0, height);
26792674
}
26802675
for mb_idx in &apply_result.deferred_emission_mbs {
26812676
let mut reward_mgr = ctx.reward_manager.write().await;

development/qnet-integration/src/consensus_v2_node.rs

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -187,26 +187,11 @@ pub async fn execute(effects: Vec<Effect>, node_id: &str, p2p: &Arc<SimplifiedP2
187187
// it == qc.checkpoint_hash (binds this exact block), and full-verify the QC.
188188
let qc_bytes = bincode::serialize(&(checkpoint.clone(), qc.clone())).unwrap_or_default();
189189
let excluded = excluded_producers(storage, window);
190-
// Emission macroblock (every 160 windows ≈ 4h): record the PREVIOUS epoch's
191-
// reward recipients on-chain (Super HBC + Light pings) so the emission TX and
192-
// the deterministic crediting (both read these fields) work under v2. Determ-
193-
// inistic ⇒ every sealer records the same set. pool2 (fees→producer since
194-
// v3.18) / pool3 (Phase 2) stay None. Heavy epoch scan, but 1 window in 160.
195-
let (reward_heartbeats, reward_light_nodes) = if window > 0 && window % 160 == 0 {
196-
let ws = (window / 160 - 1) * 14400;
197-
let we = ws + 14400;
198-
// v35: discover recipients from on-chain Heartbeat-TX emitters and key
199-
// eligibility on the UNFORGEABLE per-epoch tally (heartbeat_slots popcount).
200-
// No HBC scan, no self-attested count; sorted output ⇒ identical body on all sealers.
201-
let hb = crate::node::BlockchainNode::collect_heartbeat_summaries_from_chain(storage, ws, we)
202-
.await.ok()
203-
.filter(|s| !s.is_empty())
204-
.and_then(|s| bincode::serialize(&s).ok());
205-
let lt = crate::node::BlockchainNode::collect_ping_commitments_from_blocks(storage, p2p, ws, we)
206-
.await.ok().filter(|m| !m.is_empty())
207-
.and_then(|m| bincode::serialize(&m).ok());
208-
(hb, lt)
209-
} else { (None, None) };
190+
// Reward recipients are NOT sealed in the macroblock — apply recomputes both Super
191+
// (registry + per-epoch heartbeat tally) and Light (on-chain eligibility bitmaps +
192+
// deterministic roster), giving an O(1) macroblock with an identical reward root on
193+
// every node. pool2/pool3 stay None.
194+
let _ = &p2p; // sealing removed; p2p no longer read here
210195
// v2 SCALE ANCHOR: cumulative equivocation ban-set as of this window (prev
211196
// macroblock's set ∪ this window's verified proofs), sorted for byte-stable
212197
// bincode. Lets the next epoch's reputation fold derive bans in O(window)
@@ -232,8 +217,8 @@ pub async fn execute(effects: Vec<Effect>, node_id: &str, p2p: &Arc<SimplifiedP2
232217
excluded_producers_for_next_epoch: excluded,
233218
consensus_committee: Some(committee),
234219
banned_validators,
235-
reward_heartbeats,
236-
reward_light_nodes,
220+
reward_heartbeats: None,
221+
reward_light_nodes: None,
237222
..Default::default()
238223
},
239224
previous_hash,

0 commit comments

Comments
 (0)