@@ -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 ( ) ;
0 commit comments