diff --git a/Cargo.lock b/Cargo.lock index 1c5eb2517..85be80254 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4719,6 +4719,7 @@ dependencies = [ "rpds", "serde", "serde_arrays", + "serde_json", "thiserror 2.0.18", "tracing", ] diff --git a/consensus/cryptarchia-engine/src/time.rs b/consensus/cryptarchia-engine/src/time.rs index 8b554b08f..83427219c 100644 --- a/consensus/cryptarchia-engine/src/time.rs +++ b/consensus/cryptarchia-engine/src/time.rs @@ -182,6 +182,11 @@ impl Slot { pub const fn saturating_sub(self, rhs: Self) -> Self { Self(self.0.saturating_sub(rhs.0)) } + + #[must_use] + pub fn checked_sub(self, rhs: Self) -> Option { + self.0.checked_sub(rhs.0).map(Self) + } } impl From for Epoch { diff --git a/core/src/events/mod.rs b/core/src/events/mod.rs index 0501a6573..f4eb8cf99 100644 --- a/core/src/events/mod.rs +++ b/core/src/events/mod.rs @@ -10,6 +10,7 @@ use crate::{ ops::{ channel::{ChannelId, deposit::Metadata}, leader_claim::VoucherNullifier, + pow::PowNullifier, }, transactions::hash::TxHash, }, @@ -117,6 +118,11 @@ pub enum TxEventPayload { voucher_nullifier: VoucherNullifier, utxo: Utxo, }, + /// A `PoW` claim operation created a reward note for its beneficiary + PoWRewardClaimed { + pow_nullifier: PowNullifier, + utxo: Utxo, + }, } /// Events emitted while processing a block header diff --git a/core/src/mantle/ops/pow.rs b/core/src/mantle/ops/pow.rs index fbaf6339f..8e4d0e4e5 100644 --- a/core/src/mantle/ops/pow.rs +++ b/core/src/mantle/ops/pow.rs @@ -1,28 +1,34 @@ -use std::collections::HashMap; - use ark_ff::Zero as _; -use lb_codec::BinaryCodec; -use lb_cryptarchia_engine::Epoch; +use lb_codec::{BinaryCodec, BinaryEncode as _}; +use lb_cryptarchia_engine::{Epoch, Slot}; use lb_groth16::{Fr, fr_from_mod_bytes, serde::serde_fr}; use lb_key_management_system_keys::keys::ZkPublicKey; +use rpds::HashTrieMapSync; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{ crypto::{Hash, ZkDigest as _, ZkHash, ZkHasher}, - events::TxEvent, + events::{TxEvent, TxEventPayload}, mantle::{ + Note, TxHash, Utxo, Value, ledger::{ - ExecutableOperation, PreverifiableOperation, ProvableOperation, VerifiableOperation, - verification_mode, + ExecutableOperation, PreverifiableOperation, ProvableOperation, Utxos, + VerifiableOperation, verification_mode, }, - ops::NoOpProof, + ops::{NoOpProof, OpId}, }, }; +pub const SLOT_WINDOW: u64 = 100; +/// `d_reward`: the difficulty threshold a puzzle ticket must be strictly +/// below to qualify for a `PoW` reward claim. pub type PowTarget = Fr; -pub type PowReward = u64; +/// A `PoW` reward amount, denominated like any other note [`Value`]. +pub type PowReward = Value; +/// Nullifier of a spent `PoW` solution, recorded on claim to prevent the same +/// solution from being claimed twice. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize, BinaryCodec)] pub struct PowNullifier(#[serde(with = "serde_fr")] ZkHash); @@ -33,6 +39,8 @@ impl PowNullifier { } } +/// The ticket derived from a claim's inputs, checked against the reward +/// [`PowTarget`] and, once accepted, recorded as a [`PowNullifier`]. pub type PuzzleTicket = PowNullifier; impl From for PowNullifier { @@ -47,15 +55,27 @@ impl From for ZkHash { } } +/// Operation claiming the `PoW` reward for a solved puzzle. +/// +/// The puzzle solution is not carried directly on the op: it is proven by +/// deriving a [`PuzzleTicket`] from `epoch_nonce`, `block_hash` and +/// `public_key` (see [`Self::get_puzzle_ticket`]) and checking that ticket +/// against the current reward difficulty during validation. #[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize, BinaryCodec)] pub struct ClaimPowRewardOp { + /// Epoch nonce the puzzle was solved against; must match the current or + /// previous epoch nonce. #[serde(with = "serde_fr")] pub epoch_nonce: ZkHash, + /// Hash of the block the puzzle solution is anchored to. pub block_hash: Hash, + /// Public key of the reward beneficiary. pub public_key: ZkPublicKey, } impl ClaimPowRewardOp { + /// Derive this claim's [`PuzzleTicket`] from `epoch_nonce`, `block_hash` + /// and `public_key`. #[must_use] pub fn get_puzzle_ticket(&self) -> PuzzleTicket { PowNullifier(ZkHasher::digest(&[ @@ -66,6 +86,7 @@ impl ClaimPowRewardOp { } } +/// Errors returned while validating a [`ClaimPowRewardOp`]. #[derive(Clone, Debug, Error, Eq, PartialEq)] pub enum ClaimPowRewardError { #[error("Insufficient pool ({pool}) for reward ({reward})")] @@ -81,23 +102,34 @@ pub enum ClaimPowRewardError { InvalidPoWRewardTicket, #[error("Ticket was already claimed")] DoubleClaimed, - #[error("Out of window height ({height})")] - OutOfWindowHeight { height: u64 }, + #[error("Out of window slot ({slot:?}) vs block slot ({current_slot:?})")] + OutOfWindowSlot { slot: Slot, current_slot: Slot }, #[error("Missing block ({block_id:?})")] MissingBlock { block_id: Hash }, } +/// Ledger context needed to validate a [`ClaimPowRewardOp`]. pub struct ClaimPoWRewardVerificationContext<'a> { // As per spec - pub current_block_height: u64, + /// Slot of the block the claim is being validated in. + pub current_block_slot: Slot, + /// `d_reward`: current reward difficulty a puzzle ticket must meet. pub reward_difficulty: PowTarget, - pub pow_nullifiers: &'a rpds::HashTrieSetSync, + /// Nullifiers of already-claimed `PoW` solutions, mapped to the slot + /// they were claimed at. + pub pow_nullifiers: &'a HashTrieMapSync, // needed not in spec yet + /// `sigma_e`: reward amount per claim for the current epoch. pub epoch_pow_reward: PowReward, + /// `R_PoW`: current balance of the `PoW` reward pool. pub epoch_reward_pool: PowReward, - pub current_epoch_nonce: Epoch, - pub previous_epoch_nonce: Epoch, - pub blocks_height: HashMap, + /// Nonce of the current epoch. + pub current_epoch: Epoch, + /// Nonce of the previous epoch, also accepted for claims. + pub previous_epoch: Epoch, + /// Slots of known blocks, used to check the claim's block is within + /// the acceptance window. + pub blocks_slot: HashTrieMapSync, } impl ClaimPoWRewardVerificationContext<'_> { @@ -107,32 +139,29 @@ impl ClaimPoWRewardVerificationContext<'_> { if self.epoch_pow_reward.is_zero() { return Err(ClaimPowRewardError::EmptyRewards); } - if self.epoch_reward_pool <= self.epoch_pow_reward { - return Err(ClaimPowRewardError::InsufficientPoolBalance { - pool: self.epoch_reward_pool, - reward: self.epoch_pow_reward, - }); - } + self.validate_enough_funds_in_pool()?; Ok(()) } - /// On-chain `block_hash` window check + /// On-chain `block_hash` window check, measured in slots. pub fn accept_claim( &self, block_id: Hash, ) -> Result<(), ClaimPowRewardError> { - let Some(&block_height) = self.blocks_height.get(&block_id) else { + let Some(&block_slot) = self.blocks_slot.get(&block_id) else { return Err(ClaimPowRewardError::MissingBlock { block_id }); }; - let Some(check_height) = self.current_block_height.checked_sub(block_height) else { - return Err(ClaimPowRewardError::OutOfWindowHeight { - height: block_height, + let Some(slot_gap) = self.current_block_slot.checked_sub(block_slot) else { + return Err(ClaimPowRewardError::OutOfWindowSlot { + slot: block_slot, + current_slot: self.current_block_slot, }); }; - if check_height > WINDOW { - return Err(ClaimPowRewardError::OutOfWindowHeight { - height: block_height, + if slot_gap > Slot::from(WINDOW) { + return Err(ClaimPowRewardError::OutOfWindowSlot { + slot: block_slot, + current_slot: self.current_block_slot, }); } Ok(()) @@ -144,14 +173,14 @@ impl ClaimPoWRewardVerificationContext<'_> { claim_epoch_nonce: ZkHash, ) -> Result<(), ClaimPowRewardError> { let previous_epoch_nonce = ZkHasher::digest(&[fr_from_mod_bytes( - &self.previous_epoch_nonce.into_inner().to_le_bytes(), + &self.previous_epoch.into_inner().to_le_bytes(), )]); if claim_epoch_nonce == previous_epoch_nonce { return Ok(()); } let current_epoch_nonce = ZkHasher::digest(&[fr_from_mod_bytes( - &self.current_epoch_nonce.into_inner().to_le_bytes(), + &self.current_epoch.into_inner().to_le_bytes(), )]); if claim_epoch_nonce == current_epoch_nonce { return Ok(()); @@ -159,34 +188,78 @@ impl ClaimPoWRewardVerificationContext<'_> { Err(ClaimPowRewardError::MismatchEpochNonce { claim: claim_epoch_nonce, - accepted: (self.previous_epoch_nonce, self.current_epoch_nonce), + accepted: (self.previous_epoch, self.current_epoch), }) } + /// The puzzle ticket must be strictly below the current reward + /// difficulty (§5.3: `puzzle_ticket < difficulty_reward`). fn validate_difficulty_reward( &self, puzzle_ticket: PuzzleTicket, ) -> Result<(), ClaimPowRewardError> { let ticket_as_fr = *puzzle_ticket.as_fr(); - if ticket_as_fr > self.reward_difficulty { + if ticket_as_fr >= self.reward_difficulty { return Err(ClaimPowRewardError::InvalidPoWRewardTicket); } Ok(()) } + /// The puzzle ticket must not already have been claimed. fn validate_double_claiming( &self, puzzle_ticket: PuzzleTicket, ) -> Result<(), ClaimPowRewardError> { - if self.pow_nullifiers.contains(&puzzle_ticket) { + if self.pow_nullifiers.contains_key(&puzzle_ticket) { return Err(ClaimPowRewardError::DoubleClaimed); } Ok(()) } + + /// Validate enough funds available + const fn validate_enough_funds_in_pool(&self) -> Result<(), ClaimPowRewardError> { + if self.epoch_reward_pool < self.epoch_pow_reward { + return Err(ClaimPowRewardError::InsufficientPoolBalance { + pool: self.epoch_reward_pool, + reward: self.epoch_pow_reward, + }); + } + Ok(()) + } } +impl OpId for ClaimPowRewardOp { + fn op_bytes(&self) -> Vec { + self.encode_to_vec() + } +} + +/// Ledger context needed to execute a [`ClaimPowRewardOp`], and the outcome +/// carried back out to update the ledger's `PoW` state. pub struct ClaimPoWRewardExecutionContext { - _phantom: std::marker::PhantomData<()>, // fake content to be removed + /// `R_PoW`: current balance of the `PoW` reward pool. + pub reward_pool: PowReward, + /// `sigma_e`: reward amount paid out by this claim. + pub epoch_reward: PowReward, + /// Nullifiers of already-claimed `PoW` solutions. + pub nullifiers: HashTrieMapSync, + /// Hash of the transaction carrying this claim. + pub tx_hash: TxHash, + /// Unspent transaction outputs, extended with the reward note. + pub utxos: Utxos, + /// Recorded block slots + pub block_slots: HashTrieMapSync, +} + +impl ClaimPoWRewardExecutionContext { + /// Deduct the paid-out `epoch_reward` from the `reward_pool`. + /// This should always pass if the verification does it job, but + /// double-checking is no problem + const fn decrement_reward_pool(&mut self) { + self.reward_pool = self.reward_pool.checked_sub(self.epoch_reward).expect( + "Pool funding is check in validation so this computation should always be valid", + ); + } } impl ProvableOperation for ClaimPowRewardOp { @@ -212,8 +285,7 @@ impl VerifiableOperation for ClaimPowRewardOp { fn verify(&self, _proof: &Self::Proof, context: &Self::Context<'_>) -> Result<(), Self::Error> { context.are_pow_reward_enabled()?; - // TODO Plug constant window - context.accept_claim::<100>(self.block_hash)?; + context.accept_claim::<{ SLOT_WINDOW }>(self.block_hash)?; context.validate_current_epoch_nonce(self.epoch_nonce)?; let puzzle_ticket = self.get_puzzle_ticket(); context.validate_difficulty_reward(puzzle_ticket)?; @@ -228,8 +300,361 @@ impl ExecutableOperation for ClaimPowRewardOp { fn execute<'a>( &self, - _context: Self::Context<'a>, + mut context: Self::Context<'a>, ) -> Result<(Self::Context<'a>, Vec), Self::Error> { - todo!("Execution for ClaimPowReward is not integrated yet") + let slot = context + .block_slots + .get(&self.block_hash) + .expect("Existence should be check in verification"); + // add the nullifier to the set + let nullifier = self.get_puzzle_ticket(); + context.nullifiers.insert_mut(nullifier, *slot); + // create output note + let note = Note::new(context.epoch_reward, self.public_key); + let op_id = self.op_id(); + let utxo = Utxo { + op_id, + output_index: 0, + note, + }; + context.utxos = context.utxos.insert(utxo.id(), utxo).0; + // decrement current pool + context.decrement_reward_pool(); + // output event + let tx_hash = context.tx_hash; + Ok(( + context, + vec![TxEvent::new( + tx_hash, + op_id, + TxEventPayload::PoWRewardClaimed { + pow_nullifier: nullifier, + utxo, + }, + )], + )) + } +} + +#[cfg(test)] +mod tests { + use lb_groth16::{AdditiveGroup as _, Field as _}; + + use super::*; + + fn validation_context( + nullifiers: &HashTrieMapSync, + epoch_pow_reward: PowReward, + epoch_reward_pool: PowReward, + ) -> ClaimPoWRewardVerificationContext<'_> { + ClaimPoWRewardVerificationContext { + current_block_slot: Slot::from(0u64), + reward_difficulty: PowTarget::default(), + pow_nullifiers: nullifiers, + epoch_pow_reward, + epoch_reward_pool, + current_epoch: 0.into(), + previous_epoch: 0.into(), + blocks_slot: HashTrieMapSync::new_sync(), + } + } + + #[test] + fn pow_reward_enabled_accepts_pool_exactly_covering_the_reward() { + // Spec §5.6: claiming is enabled when `pow_reward_pool >= sigma_e`. + // A pool exactly equal to the reward must be claimable; rejecting it + // (as the previous `<=` comparison did) strands the last reward. + let nullifiers = HashTrieMapSync::new_sync(); + let ctx = validation_context(&nullifiers, 10, 10); + assert_eq!(ctx.are_pow_reward_enabled(), Ok(())); + } + + #[test] + fn pow_reward_enabled_rejects_pool_below_the_reward() { + let nullifiers = HashTrieMapSync::new_sync(); + let ctx = validation_context(&nullifiers, 10, 9); + assert_eq!( + ctx.are_pow_reward_enabled(), + Err(ClaimPowRewardError::InsufficientPoolBalance { + pool: 9, + reward: 10, + }) + ); + } + + #[test] + fn pow_reward_enabled_rejects_zero_reward() { + // sigma_e == 0 is the safety cutoff: claims are rejected outright, + // regardless of the pool balance. + let nullifiers = HashTrieMapSync::new_sync(); + let ctx = validation_context(&nullifiers, 0, 1_000); + assert_eq!( + ctx.are_pow_reward_enabled(), + Err(ClaimPowRewardError::EmptyRewards) + ); + } + + const CURRENT_EPOCH: u32 = 5; + const PREVIOUS_EPOCH: u32 = 4; + const CLAIM_BLOCK_HASH: Hash = [1u8; 32]; + + /// The epoch nonce `validate_current_epoch_nonce` derives for an epoch. + fn nonce_for_epoch(epoch: u32) -> ZkHash { + ZkHasher::digest(&[fr_from_mod_bytes(&epoch.to_le_bytes())]) + } + + fn claim_op(epoch: u32) -> ClaimPowRewardOp { + ClaimPowRewardOp { + epoch_nonce: nonce_for_epoch(epoch), + block_hash: CLAIM_BLOCK_HASH, + public_key: ZkPublicKey::new(Fr::from(42u64)), + } + } + + /// A context that accepts `claim_op(CURRENT_EPOCH)`: funded pool, + /// permissive difficulty, claim block a few slots back. + fn accepting_context( + nullifiers: &HashTrieMapSync, + ) -> ClaimPoWRewardVerificationContext<'_> { + ClaimPoWRewardVerificationContext { + current_block_slot: Slot::from(50u64), + // p - 1, the largest field element: every ticket passes. + reward_difficulty: -Fr::ONE, + pow_nullifiers: nullifiers, + epoch_pow_reward: 10, + epoch_reward_pool: 1_000, + current_epoch: CURRENT_EPOCH.into(), + previous_epoch: PREVIOUS_EPOCH.into(), + blocks_slot: std::iter::once((CLAIM_BLOCK_HASH, Slot::from(45u64))).collect(), + } + } + + #[test] + fn puzzle_ticket_is_deterministic_and_binds_every_field() { + let op = claim_op(CURRENT_EPOCH); + assert_eq!( + op.get_puzzle_ticket(), + claim_op(CURRENT_EPOCH).get_puzzle_ticket() + ); + + // Spec §3: the ticket commits to all three fields, so changing the + // epoch nonce (cross-epoch replay), the anchored block, or the + // beneficiary key each invalidates the solution. + let mut other_epoch = op.clone(); + other_epoch.epoch_nonce = nonce_for_epoch(CURRENT_EPOCH + 1); + assert_ne!(op.get_puzzle_ticket(), other_epoch.get_puzzle_ticket()); + + let mut other_block = op.clone(); + other_block.block_hash = [2u8; 32]; + assert_ne!(op.get_puzzle_ticket(), other_block.get_puzzle_ticket()); + + let mut other_key = op.clone(); + other_key.public_key = ZkPublicKey::new(Fr::from(43u64)); + assert_ne!(op.get_puzzle_ticket(), other_key.get_puzzle_ticket()); + } + + #[test] + fn accept_claim_accepts_blocks_inside_the_window() { + let nullifiers = HashTrieMapSync::new_sync(); + let mut ctx = accepting_context(&nullifiers); + + // Gap of zero: the claim's block is the current block. + ctx.blocks_slot + .insert_mut(CLAIM_BLOCK_HASH, Slot::from(50u64)); + assert_eq!(ctx.accept_claim::<10>(CLAIM_BLOCK_HASH), Ok(())); + + // Gap exactly equal to the window is still inside it (§5.1.1: + // `0 <= current - anchor <= WINDOW`, measured in slots). + ctx.blocks_slot + .insert_mut(CLAIM_BLOCK_HASH, Slot::from(40u64)); + assert_eq!(ctx.accept_claim::<10>(CLAIM_BLOCK_HASH), Ok(())); + } + + #[test] + fn accept_claim_rejects_unknown_block() { + let nullifiers = HashTrieMapSync::new_sync(); + let ctx = accepting_context(&nullifiers); + let unknown = [9u8; 32]; + assert_eq!( + ctx.accept_claim::<10>(unknown), + Err(ClaimPowRewardError::MissingBlock { block_id: unknown }) + ); + } + + #[test] + fn accept_claim_rejects_block_beyond_the_window() { + let nullifiers = HashTrieMapSync::new_sync(); + let mut ctx = accepting_context(&nullifiers); + // Gap of WINDOW + 1: one slot too old. + ctx.blocks_slot + .insert_mut(CLAIM_BLOCK_HASH, Slot::from(39u64)); + assert_eq!( + ctx.accept_claim::<10>(CLAIM_BLOCK_HASH), + Err(ClaimPowRewardError::OutOfWindowSlot { + slot: Slot::from(39u64), + current_slot: Slot::from(50), + }) + ); + } + + #[test] + fn accept_claim_rejects_block_from_the_future() { + let nullifiers = HashTrieMapSync::new_sync(); + let mut ctx = accepting_context(&nullifiers); + // The claim's block is ahead of the current slot (negative gap), + // e.g. a hash from a competing, longer branch. + ctx.blocks_slot + .insert_mut(CLAIM_BLOCK_HASH, Slot::from(51u64)); + assert_eq!( + ctx.accept_claim::<10>(CLAIM_BLOCK_HASH), + Err(ClaimPowRewardError::OutOfWindowSlot { + slot: Slot::from(51u64), + current_slot: Slot::from(50), + }) + ); + } + + #[test] + fn validate_accepts_claim_with_current_epoch_nonce() { + let nullifiers = HashTrieMapSync::new_sync(); + let ctx = accepting_context(&nullifiers); + assert_eq!(claim_op(CURRENT_EPOCH).verify(&NoOpProof, &ctx), Ok(())); + } + + #[test] + fn validate_accepts_claim_with_previous_epoch_nonce() { + // Spec §5.3 step 3: a solution mined just before an epoch boundary + // stays claimable, so the previous epoch's nonce is also accepted. + let nullifiers = HashTrieMapSync::new_sync(); + let ctx = accepting_context(&nullifiers); + assert_eq!(claim_op(PREVIOUS_EPOCH).verify(&NoOpProof, &ctx), Ok(())); + } + + #[test] + fn validate_rejects_claim_with_stale_epoch_nonce() { + let nullifiers = HashTrieMapSync::new_sync(); + let ctx = accepting_context(&nullifiers); + let op = claim_op(PREVIOUS_EPOCH - 1); + assert_eq!( + op.verify(&NoOpProof, &ctx), + Err(ClaimPowRewardError::MismatchEpochNonce { + claim: op.epoch_nonce, + accepted: (PREVIOUS_EPOCH.into(), CURRENT_EPOCH.into()), + }) + ); + } + + #[test] + fn validate_rejects_ticket_above_the_reward_difficulty() { + let nullifiers = HashTrieMapSync::new_sync(); + let mut ctx = accepting_context(&nullifiers); + // The hardest possible target: only a ticket of exactly zero would + // pass, and this op's ticket is not zero. + ctx.reward_difficulty = Fr::ZERO; + assert_eq!( + claim_op(CURRENT_EPOCH).verify(&NoOpProof, &ctx), + Err(ClaimPowRewardError::InvalidPoWRewardTicket) + ); + } + + #[test] + fn validate_rejects_ticket_equal_to_the_reward_difficulty() { + // Spec §5.3: the check is the strict `puzzle_ticket < + // difficulty_reward`, so a ticket exactly on the target does not + // qualify. + let nullifiers = HashTrieMapSync::new_sync(); + let op = claim_op(CURRENT_EPOCH); + let mut ctx = accepting_context(&nullifiers); + ctx.reward_difficulty = op.get_puzzle_ticket().into(); + assert_eq!( + op.verify(&NoOpProof, &ctx), + Err(ClaimPowRewardError::InvalidPoWRewardTicket) + ); + } + + #[test] + fn validate_rejects_already_claimed_ticket() { + let op = claim_op(CURRENT_EPOCH); + let nullifiers = + HashTrieMapSync::new_sync().insert(op.get_puzzle_ticket(), Slot::from(45u64)); + let ctx = accepting_context(&nullifiers); + assert_eq!( + op.verify(&NoOpProof, &ctx), + Err(ClaimPowRewardError::DoubleClaimed) + ); + } + + #[test] + fn execute_issues_reward_utxo_and_registers_nullifier() { + let op = claim_op(CURRENT_EPOCH); + let epoch_reward = 10; + let tx_hash = TxHash::from([11u8; 32]); + + let (ctx, events) = op + .execute(ClaimPoWRewardExecutionContext { + reward_pool: 1_000, + epoch_reward, + nullifiers: HashTrieMapSync::new_sync(), + tx_hash, + utxos: Utxos::new(), + block_slots: std::iter::once((CLAIM_BLOCK_HASH, Slot::from(45u64))).collect(), + }) + .expect("claim execution should succeed"); + + // The spent solution is recorded against the anchor block's slot, + // and the pool pays out sigma_e. + assert_eq!( + ctx.nullifiers.get(&op.get_puzzle_ticket()), + Some(&Slot::from(45u64)) + ); + assert_eq!(ctx.reward_pool, 990); + + // The reward note lands in the UTXO set, payable to the op's key + // (§5.3 execution step 3). Regression: the persistent-tree insert + // result used to be discarded, so the note never reached the set. + let expected_utxo = Utxo { + op_id: op.op_id(), + output_index: 0, + note: Note::new(epoch_reward, op.public_key), + }; + assert_eq!(ctx.utxos.get(&expected_utxo.id()), Some(expected_utxo)); + + let mut events = events.iter(); + let Some(TxEvent { + tx_hash: event_tx_hash, + op_id, + payload: + TxEventPayload::PoWRewardClaimed { + pow_nullifier, + utxo, + }, + }) = events.next() + else { + panic!("expected PoWRewardClaimed tx event"); + }; + assert_eq!(*event_tx_hash, tx_hash); + assert_eq!(*op_id, op.op_id()); + assert_eq!(*pow_nullifier, op.get_puzzle_ticket()); + assert_eq!(*utxo, expected_utxo); + assert!(events.next().is_none()); + } + + #[test] + #[should_panic(expected = "Pool funding is check in validation")] + fn execute_panics_when_pool_cannot_cover_the_reward() { + // Verification (`validate_enough_funds_in_pool`) is the guard that + // keeps uncoverable claims out of blocks — a builder must never + // include one. Execution treats a shortfall as a broken invariant + // and aborts loudly rather than minting a reward note the pool + // cannot back. + let op = claim_op(CURRENT_EPOCH); + drop(op.execute(ClaimPoWRewardExecutionContext { + reward_pool: 5, + epoch_reward: 10, + nullifiers: HashTrieMapSync::new_sync(), + tx_hash: TxHash::from([11u8; 32]), + utxos: Utxos::new(), + block_slots: std::iter::once((CLAIM_BLOCK_HASH, Slot::from(45u64))).collect(), + })); } } diff --git a/core/src/mantle/transactions/errors.rs b/core/src/mantle/transactions/errors.rs index c37fa95e2..690b8b625 100644 --- a/core/src/mantle/transactions/errors.rs +++ b/core/src/mantle/transactions/errors.rs @@ -51,4 +51,6 @@ pub enum VerificationError { SDPVerificationError(crate::mantle::ops::sdp::SdpError), #[error("LeaderClaim verification error: {0}")] LeaderClaimVerificationError(crate::mantle::ops::leader_claim::LeaderClaimError), + #[error("ClaimPoWReward verification error: {0}")] + ClaimPowRewardError(crate::mantle::ops::pow::ClaimPowRewardError), } diff --git a/core/src/mantle/transactions/signed_mantle_tx.rs b/core/src/mantle/transactions/signed_mantle_tx.rs index bed93fe7e..bea8e654f 100644 --- a/core/src/mantle/transactions/signed_mantle_tx.rs +++ b/core/src/mantle/transactions/signed_mantle_tx.rs @@ -18,6 +18,7 @@ use crate::{ withdraw::WithdrawValidationContext, }, leader_claim::{LeaderClaimPreverificationContext, LeaderClaimVerificationContext}, + pow::ClaimPoWRewardVerificationContext, sdp::{ SDPActiveValidationContext, SDPDeclareOp, SDPDeclareVerificationContext, SDPWithdrawValidationContext, declare::SDPDeclarePreverificationContext, @@ -155,6 +156,9 @@ impl SignedMantleTx { (Op::Transfer(op), OpProof::ZkSig(proof)) => op .preverify(proof, &()) .map_err(VerificationError::TransferVerificationError), + (Op::ClaimPowReward(op), OpProof::None(proof)) => op + .preverify(proof, &()) + .map_err(VerificationError::ClaimPowRewardError), _ => Err(VerificationError::IncorrectProofType { op_type: op.as_str(), op_index, @@ -334,6 +338,21 @@ impl SignedMantleTx { op.verify(proof, &context) .map_err(VerificationError::TransferVerificationError) } + (Op::ClaimPowReward(claim_pow_op), OpProof::None(proof)) => { + let context = ClaimPoWRewardVerificationContext { + current_block_slot: helper.get_block_slot(), + reward_difficulty: helper.get_pow_reward_difficulty(), + pow_nullifiers: helper.get_pow_nullifiers(), + epoch_pow_reward: helper.get_epoch_pow_reward(), + epoch_reward_pool: helper.get_pow_reward_pool(), + current_epoch: helper.get_epoch(), + previous_epoch: helper.get_previous_epoch(), + blocks_slot: helper.get_blocks_slot(), + }; + claim_pow_op + .verify(proof, &context) + .map_err(VerificationError::ClaimPowRewardError) + } // SignedMantleTx invariant: Op/Proof pairs have been verified in // preverify, so this branch should be unreachable. _ => { diff --git a/core/src/mantle/transactions/verification_helper.rs b/core/src/mantle/transactions/verification_helper.rs index 2346a984e..cd7325f08 100644 --- a/core/src/mantle/transactions/verification_helper.rs +++ b/core/src/mantle/transactions/verification_helper.rs @@ -1,7 +1,9 @@ use lb_cryptarchia_engine::{Epoch, Slot}; use lb_key_management_system_keys::keys::Ed25519PublicKey; +use rpds::HashTrieMapSync; use crate::{ + crypto::Hash, mantle::{ VerificationError, channel::Channels, @@ -9,6 +11,7 @@ use crate::{ ops::{ channel::{ChannelId, ChannelKeyIndex}, leader_claim::{RewardsRoot, VoucherNullifier}, + pow::{PowNullifier, PowReward, PowTarget}, }, }, sdp::{DeclarationId, MinStake, ServiceType, locked_notes::LockedNotes}, @@ -51,6 +54,34 @@ pub trait OperationVerificationHelper { channel_id: &ChannelId, key_index: &ChannelKeyIndex, ) -> Result; + + // `PoW` claim validation inputs, one per + // [`ClaimPoWRewardVerificationContext`] field. The current epoch comes from + // [`Self::get_epoch`] and the current block slot from + // [`Self::get_block_slot`]. + // + // [`ClaimPoWRewardVerificationContext`]: crate::mantle::ops::pow::ClaimPoWRewardVerificationContext + + /// `d_reward`: the reward difficulty a puzzle ticket must be strictly + /// below. + fn get_pow_reward_difficulty(&self) -> PowTarget; + + /// Nullifiers of already-claimed `PoW` solutions. + fn get_pow_nullifiers(&self) -> &HashTrieMapSync; + + /// `sigma_e`: reward amount per claim for the current epoch. + fn get_epoch_pow_reward(&self) -> PowReward; + + /// `R_PoW`: current balance of the `PoW` reward pool. + fn get_pow_reward_pool(&self) -> PowReward; + + /// The epoch preceding [`Self::get_epoch`], whose nonce is also accepted + /// for claims mined just before an epoch boundary. + fn get_previous_epoch(&self) -> Epoch; + + /// Slots of the blocks a claim may anchor to, keyed by block hash; + /// used for the window-of-acceptance check. + fn get_blocks_slot(&self) -> HashTrieMapSync; } #[cfg(test)] @@ -58,9 +89,10 @@ pub mod test_utils { use std::collections::HashMap; use lb_cryptarchia_engine::{Epoch, Slot}; - use rpds::HashTrieSetSync; + use rpds::{HashTrieMapSync, HashTrieSetSync}; use crate::{ + crypto::Hash, mantle::{ Utxo, VerificationError, channel::Channels, @@ -68,6 +100,7 @@ pub mod test_utils { ops::{ channel::{ChannelId, ChannelKeyIndex, Ed25519PublicKey}, leader_claim::{RewardsRoot, VoucherNullifier}, + pow::{PowNullifier, PowReward, PowTarget}, }, transactions::OperationVerificationHelper, }, @@ -85,6 +118,12 @@ pub mod test_utils { block_slot: Slot, nullifiers: HashTrieSetSync, claimable_vouchers_root: RewardsRoot, + pow_reward_difficulty: PowTarget, + pow_nullifiers: HashTrieMapSync, + epoch_pow_reward: PowReward, + pow_reward_pool: PowReward, + previous_epoch: Epoch, + blocks_slot: HashTrieMapSync, } impl TestOperationVerificationHelper { @@ -107,6 +146,12 @@ pub mod test_utils { block_slot: Slot::from(0u64), nullifiers: HashTrieSetSync::new_sync(), claimable_vouchers_root: RewardsRoot::default(), + pow_reward_difficulty: PowTarget::default(), + pow_nullifiers: HashTrieMapSync::new_sync(), + epoch_pow_reward: 0, + pow_reward_pool: 0, + previous_epoch: Epoch::from(0u32), + blocks_slot: HashTrieMapSync::new_sync(), } } @@ -117,6 +162,54 @@ pub mod test_utils { } self } + + #[must_use] + pub const fn with_block_slot(mut self, slot: Slot) -> Self { + self.block_slot = slot; + self + } + + #[must_use] + pub const fn with_pow_reward_difficulty(mut self, difficulty: PowTarget) -> Self { + self.pow_reward_difficulty = difficulty; + self + } + + #[must_use] + pub const fn with_pow_rewards( + mut self, + epoch_pow_reward: PowReward, + pow_reward_pool: PowReward, + ) -> Self { + self.epoch_pow_reward = epoch_pow_reward; + self.pow_reward_pool = pow_reward_pool; + self + } + + #[must_use] + pub fn with_pow_nullifiers( + mut self, + nullifiers: HashTrieMapSync, + ) -> Self { + self.pow_nullifiers = nullifiers; + self + } + + #[must_use] + pub fn with_epochs(mut self, previous: Epoch, current: Epoch) -> Self { + self.previous_epoch = previous; + self.epoch = current; + self + } + + #[must_use] + pub fn with_blocks_slot( + mut self, + blocks_slot: impl IntoIterator, + ) -> Self { + self.blocks_slot = blocks_slot.into_iter().collect(); + self + } } impl OperationVerificationHelper for TestOperationVerificationHelper { @@ -191,5 +284,29 @@ pub mod test_utils { }, ) } + + fn get_pow_reward_difficulty(&self) -> PowTarget { + self.pow_reward_difficulty + } + + fn get_pow_nullifiers(&self) -> &HashTrieMapSync { + &self.pow_nullifiers + } + + fn get_epoch_pow_reward(&self) -> PowReward { + self.epoch_pow_reward + } + + fn get_pow_reward_pool(&self) -> PowReward { + self.pow_reward_pool + } + + fn get_previous_epoch(&self) -> Epoch { + self.previous_epoch + } + + fn get_blocks_slot(&self) -> HashTrieMapSync { + self.blocks_slot.clone() + } } } diff --git a/ledger/Cargo.toml b/ledger/Cargo.toml index 5cceb2489..b649cc887 100644 --- a/ledger/Cargo.toml +++ b/ledger/Cargo.toml @@ -34,7 +34,8 @@ thiserror = { workspace = true } tracing = { workspace = true } [dev-dependencies] -rand = { features = ["std", "std_rng"], workspace = true } +rand = { features = ["std", "std_rng"], workspace = true } +serde_json = { features = ["alloc"], workspace = true } [package.metadata.cargo-machete] ignored = ["serde_arrays"] diff --git a/ledger/src/lib.rs b/ledger/src/lib.rs index a93481d31..c4797c9fc 100644 --- a/ledger/src/lib.rs +++ b/ledger/src/lib.rs @@ -13,7 +13,8 @@ use cryptarchia::LedgerState as CryptarchiaLedger; pub use cryptarchia::{EpochState, UtxoTree}; use lb_core::{ block::BlockNumber, - events::{Events, HeaderEvent, TxEvent}, + crypto::Hash as BlockHash, + events::{Events, HeaderEvent, TxEvent, TxEventPayload}, mantle::{ NoteId, Op, Utxo, Value, VerificationError, gas::{Gas, GasConstants, GasCost, GasOverflow}, @@ -24,6 +25,7 @@ use lb_core::{ deposit::DepositExecutionContext, withdraw::WithdrawExecutionContext, }, leader_claim::LeaderClaimExecutionContext, + pow::{ClaimPoWRewardExecutionContext, PowReward}, }, traits::{GenesisTx, MantleTxWithProofs, PreverifiedMantleTx}, transactions::{GasPrices, MantleTxGasContext, hash::TxHash, mantle_tx::MantleTxContext}, @@ -72,6 +74,12 @@ const LEADER_REWARD_SHARE_DENOMINATOR: u128 = 10; const BLEND_REWARD_SHARE_NUMERATOR: u128 = 6; const BLEND_REWARD_SHARE_DENOMINATOR: u128 = 10; + +// `POW` related rewards +// TODO: Activate this, currently is 0 based to keep original behaviour +// (blend+leadership) +const POW_REWARD_SHARE_NUMERATOR: u128 = 0; +const POW_REWARD_SHARE_DENOMINATOR: u128 = 4; const EXECUTION_GAS_LIMIT: Gas = Gas::new(3_193_460); // While individual notes are constrained to be `u64`, intermediate calculations @@ -125,7 +133,14 @@ impl Ledger where Id: Eq + Hash + Copy, { - pub fn new(id: Id, state: LedgerState, config: Config) -> Self { + pub fn new(id: Id, mut state: LedgerState, config: Config) -> Self + where + Id: Into, + { + // Record the root block among the recently seen blocks, so early + // `PoW` reward claims can anchor to it. Later blocks are recorded + // as they are applied in `try_update`. + state.mantle_ledger.add_seen_block(id.into(), state.slot()); Self { states: HashTrieMapSync::new_sync().insert(id, state), config, @@ -149,6 +164,7 @@ where Tx: PreverifiedMantleTx + 'tx, LeaderProof: leader_proof::LeaderProof, Constants: GasConstants, + Id: Into, { let parent_state = self .states @@ -156,6 +172,7 @@ where .ok_or(LedgerError::ParentNotFound(parent_id))?; let (new_state, events) = parent_state.clone().try_update::<_, _, _, Constants>( + id, slot, proof, txs, @@ -210,6 +227,7 @@ pub struct LedgerState { impl LedgerState { fn try_update<'tx, Tx, LeaderProof, Id, Constants>( self, + block_id: Id, slot: Slot, proof: &LeaderProof, txs: impl Iterator, @@ -219,9 +237,24 @@ impl LedgerState { Tx: PreverifiedMantleTx + 'tx, LeaderProof: leader_proof::LeaderProof, Constants: GasConstants, + Id: Into, { - let (state, header_events) = self.try_apply_header(slot, proof, config)?; - let (state, tx_events) = state.try_apply_contents::<_, _, Constants>(config, txs)?; + let (mut state, header_events) = self.try_apply_header(slot, proof, config)?; + // Record the applied block among the recently seen blocks `PoW` + // claims may anchor to. This is the canonical apply path, where the + // block's id is known — unlike a proposer's direct + // `try_apply_header` call for a block still being built. + state.mantle_ledger.add_seen_block(block_id.into(), slot); + let (mut state, tx_events) = state.try_apply_contents::<_, _, Constants>(config, txs)?; + state.update_pow_difficulty( + // count all claimed rewards + tx_events + .iter() + .filter(|TxEvent { payload, .. }| { + matches!(payload, TxEventPayload::PoWRewardClaimed { .. }) + }) + .count() as u64, + ); let events = header_events .into_iter() .map(Into::into) @@ -333,12 +366,18 @@ impl LedgerState { ) .checked_add(total_fee_tip)?; + let pow_reward: PowReward = ((reward_numerator * POW_REWARD_SHARE_NUMERATOR) + / (reward_denominator * POW_REWARD_SHARE_DENOMINATOR)) + .try_into() + .map_err(|_e| GasOverflow)?; + self.mantle_ledger.leaders = self .mantle_ledger .leaders .add_pending_rewards(leader_reward.into_inner()); self.mantle_ledger.sdp.add_blend_income(blend_reward); + self.mantle_ledger.pow.add_reward_refill_rewards(pow_reward); Ok(self) } @@ -660,8 +699,24 @@ impl LedgerState { .ok_or(LedgerError::BalanceOverflow)?; tx_events.extend(events); } - Op::ClaimPowReward(_) => { - todo!("ClaimPowReward operation execution is not implemented yet"); + Op::ClaimPowReward(claim_pow_reward) => { + let (result, events) = claim_pow_reward + .execute(ClaimPoWRewardExecutionContext { + reward_pool: self.mantle_ledger.pow.reward_pool(), + // TODO: check correctness of epoch reward, as it should be from the op + // specified epoch + epoch_reward: self.mantle_ledger.pow.epoch_reward(), + nullifiers: self.mantle_ledger.pow.nullifiers().clone(), + tx_hash: *tx_hash, + utxos: self.cryptarchia_ledger.latest_utxos().clone(), + block_slots: self.mantle_ledger.pow.block_slots().clone(), + }) + .map_err(mantle::Error::ClaimPow)?; + self.mantle_ledger + .pow + .update_from_claim_execution_result(&result); + self.cryptarchia_ledger = self.cryptarchia_ledger.update_utxos(result.utxos); + tx_events.extend(events); } } @@ -718,13 +773,16 @@ impl LedgerState { Ok((self, balance, tx_events)) } + + fn update_pow_difficulty(&mut self, claims_in_block: u64) { + self.mantle_ledger.pow.update_difficulty(claims_in_block); + } } #[cfg(test)] mod tests { use cryptarchia::tests::{config, generate_proof, utxo}; use lb_core::{ - events::TxEventPayload, mantle::{ GasCalculator as _, Note, OpProof, RawMantleTx, SignedMantleTx, gas::MainnetGasConstants, @@ -1962,4 +2020,400 @@ mod tests { .unwrap_err(); assert_eq!(err, LeaderClaimError::DuplicatedVoucherNullifier); } + + mod pow { + use lb_core::{ + crypto::{ZkDigest as _, ZkHasher}, + mantle::ops::{ + NoOpProof, + pow::{ClaimPowRewardError, ClaimPowRewardOp, PowTarget}, + }, + }; + use lb_groth16::{AdditiveGroup as _, fr_from_mod_bytes}; + + use super::*; + use crate::mantle::pow::ClaimPoWConstants; + + /// A payout rate of `1/100`: `sigma_e = pool / 100`, used to give the + /// `PoW` state a nonzero per-claim reward in tests. + struct TestPoolConstants; + impl ClaimPoWConstants for TestPoolConstants { + const RATE_NUM: u64 = 1; + const RATE_DEN: u64 = 1; + const TARGET_CLAIM_PER_BLOCK: u64 = 10; + const EXPECTED_BLOCKS_PER_EPOCH: u64 = 10; + } + + /// A ledger state with a funded `PoW` pool (1000, `sigma_e` = 10) and + /// a seeded reward difficulty. + fn pow_ledger_state(reward_difficulty: u64) -> (LedgerState, Config) { + let config = config(); + let mut state = LedgerState::from_utxos([utxo()], &config); + state.mantle_ledger.pow.add_reward_refill_rewards(1_000); + state + .mantle_ledger + .pow + .add_rewards_to_pool::(); + state + .mantle_ledger + .pow + .set_reward_difficulty(PowTarget::from(reward_difficulty)); + (state, config) + } + + fn claim_op() -> ClaimPowRewardOp { + ClaimPowRewardOp { + // The nonce `validate_current_epoch_nonce` accepts for epoch + // 0, the current epoch of a fresh test ledger. + epoch_nonce: ZkHasher::digest(&[fr_from_mod_bytes(&0u32.to_le_bytes())]), + block_hash: [1u8; 32], + public_key: ZkPublicKey::new(Fr::from(42u64)), + } + } + + /// Read the reward difficulty of a block's committed state. + fn difficulty_at(ledger: &Ledger, id: HeaderId) -> PowTarget { + ledger + .state(&id) + .expect("block state should exist") + .mantle_ledger + .pow + .reward_difficulty() + } + + #[test] + fn difficulty_eases_on_each_applied_block_without_claims() { + // The retarget runs in `try_update`, on the canonical block-apply + // path, once the block's contents have applied successfully. + // `PoWDifficultySettings`: q = 9/10, T = 100. An empty block is + // the largest easing step, a factor of P/F = 10/9 per block: + // 900 -> 10·100·900/(9·100) = 1000 -> 1000000/900 = 1111. + let test_utxo = utxo(); + let (mut test_ledger, genesis) = ledger(&[test_utxo], config()); + test_ledger + .states + .get_mut(&genesis) + .expect("genesis state should exist") + .mantle_ledger + .pow + .set_reward_difficulty(PowTarget::from(900u64)); + + let block_1 = update_ledger(&mut test_ledger, genesis, 1, test_utxo) + .expect("empty block should apply"); + assert_eq!( + difficulty_at(&test_ledger, block_1), + PowTarget::from(1_000u64) + ); + + let block_2 = update_ledger(&mut test_ledger, block_1, 2, test_utxo) + .expect("empty block should apply"); + assert_eq!( + difficulty_at(&test_ledger, block_2), + PowTarget::from(1_111u64) + ); + } + + #[test] + fn difficulty_hardens_when_claims_exceed_the_target() { + // Exercises the `LedgerState` plumbing directly with a claim + // count: 2T claims shrink the target, + // 1000 -> 10·100·1000/(1·200 + 9·100) = 909. + let (mut state, _config) = pow_ledger_state(1_000); + + state.update_pow_difficulty(200); + + assert_eq!( + state.mantle_ledger.pow.reward_difficulty(), + PowTarget::from(909u64) + ); + } + + #[test] + fn difficulty_is_seeded_at_genesis_and_the_controller_can_move_it() { + // Genesis seeds a nonzero initial difficulty (zero would be an + // absorbing state for the controller, with no ticket ever able + // to satisfy it), and the per-block retarget moves it: an empty + // block (no claims) eases the target upward. + let test_utxo = utxo(); + let (mut test_ledger, genesis) = ledger(&[test_utxo], config()); + let genesis_difficulty = difficulty_at(&test_ledger, genesis); + assert_ne!(genesis_difficulty, Fr::ZERO); + + let block_1 = update_ledger(&mut test_ledger, genesis, 1, test_utxo) + .expect("empty block should apply"); + assert!(difficulty_at(&test_ledger, block_1) > genesis_difficulty); + } + + fn claim_tx() -> SignedMantleTx { + let mantle_tx = RawMantleTx([Op::ClaimPowReward(claim_op())].into()); + SignedMantleTx::new(mantle_tx, [OpProof::None(NoOpProof)].into()) + .preverify() + .expect("claim op with OpProof::None should pass preverification") + } + + #[test] + fn claim_tx_validation_rejects_disabled_rewards() { + // End-to-end through `try_apply_tx`: with `sigma_e` forced to + // zero the claim fails the §5.6 safety cutoff. This exercises + // the full wiring: preverification, the stateful + // `ClaimPowReward` arm and the helper-built context. + struct DisabledConstants; + impl ClaimPoWConstants for DisabledConstants { + const RATE_NUM: u64 = 0; + const RATE_DEN: u64 = 1; + const TARGET_CLAIM_PER_BLOCK: u64 = 1; + const EXPECTED_BLOCKS_PER_EPOCH: u64 = 1; + } + + let config = config(); + let mut state = LedgerState::from_utxos([utxo()], &config); + state + .mantle_ledger + .pow + .add_rewards_to_pool::(); + assert_eq!(state.mantle_ledger.pow.epoch_reward(), 0); + + let err = state + .try_apply_tx::<_, HeaderId, MainnetGasConstants>(&config, &claim_tx()) + .expect_err("claim should fail validation"); + + assert!(matches!( + err, + LedgerError::VerificationError(VerificationError::ClaimPowRewardError( + ClaimPowRewardError::EmptyRewards + )) + )); + } + + #[test] + fn claim_tx_validation_rejects_unknown_anchor_block() { + // With a funded pool the claim advances to the window-of- + // acceptance check, which fails because the anchor block is not + // among the ledger's recently seen blocks. + let (state, config) = pow_ledger_state(1_000); + + let err = state + .try_apply_tx::<_, HeaderId, MainnetGasConstants>(&config, &claim_tx()) + .expect_err("claim should fail validation"); + + assert!(matches!( + err, + LedgerError::VerificationError(VerificationError::ClaimPowRewardError( + ClaimPowRewardError::MissingBlock { block_id } + )) if block_id == claim_op().block_hash + )); + } + + /// A funded state where `claim_tx()` passes full validation: max + /// difficulty (every ticket wins) and the anchor block recorded as + /// seen at the current slot. + fn claim_accepting_state() -> (LedgerState, Config) { + let (mut state, config) = pow_ledger_state(1_000); + state + .mantle_ledger + .pow + .set_reward_difficulty(-PowTarget::ONE); + state + .mantle_ledger + .pow + .add_seen_block_slots(claim_op().block_hash, Slot::from(0u64)); + (state, config) + } + + #[test] + fn claim_tx_end_to_end_pays_the_reward() { + // The full §5.3 pipeline through `try_apply_tx`: preverification, + // helper-built validation context (pool, window, epoch nonce, + // difficulty, double-claim) and execution. + let (state, config) = claim_accepting_state(); + let pool_before = state.mantle_ledger.pow.reward_pool(); + let epoch_reward = state.mantle_ledger.pow.epoch_reward(); + + let (state, _balance, events) = state + .try_apply_tx::<_, HeaderId, MainnetGasConstants>(&config, &claim_tx()) + .expect("claim should validate and execute"); + + assert_eq!( + state.mantle_ledger.pow.reward_pool(), + pool_before - epoch_reward + ); + assert!( + state + .mantle_ledger + .pow + .nullifiers() + .contains_key(&claim_op().get_puzzle_ticket()) + ); + let expected_utxo = Utxo { + op_id: claim_op().op_id(), + output_index: 0, + note: Note::new(epoch_reward, claim_op().public_key), + }; + assert_eq!( + state + .cryptarchia_ledger + .latest_utxos() + .get(&expected_utxo.id()), + Some(expected_utxo) + ); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0].payload, + TxEventPayload::PoWRewardClaimed { .. } + )); + } + + #[test] + fn claim_tx_double_claim_is_rejected() { + // Replaying the same solution is caught by the nullifier check + // during tx-level validation. + let (state, config) = claim_accepting_state(); + let (state, _, _) = state + .try_apply_tx::<_, HeaderId, MainnetGasConstants>(&config, &claim_tx()) + .expect("first claim should succeed"); + + let err = state + .try_apply_tx::<_, HeaderId, MainnetGasConstants>(&config, &claim_tx()) + .expect_err("second claim should be rejected"); + + assert!(matches!( + err, + LedgerError::VerificationError(VerificationError::ClaimPowRewardError( + ClaimPowRewardError::DoubleClaimed + )) + )); + } + + #[test] + fn claim_execution_pays_the_reward_and_updates_the_ledger() { + // The execution path (`try_apply_op`): the claim drains sigma_e + // from the pool, records the nullifier, inserts the reward note + // into the UTXO set and emits the claim event. + let (mut state, config) = pow_ledger_state(1_000); + // Execution reads the anchor block's slot from the seen-block + // map (validation, skipped here, guarantees its presence), so + // record it directly. + state + .mantle_ledger + .pow + .add_seen_block_slots(claim_op().block_hash, Slot::from(0u64)); + let pool_before = state.mantle_ledger.pow.reward_pool(); + let epoch_reward = state.mantle_ledger.pow.epoch_reward(); + let op = claim_op(); + let tx_hash = TxHash::from([9u8; 32]); + + let (state, _balance, events) = state + .try_apply_op::( + &Op::ClaimPowReward(op.clone()), + &config, + &tx_hash, + 0, + Vec::new(), + ) + .expect("claim execution should succeed"); + + assert_eq!( + state.mantle_ledger.pow.reward_pool(), + pool_before - epoch_reward + ); + assert!( + state + .mantle_ledger + .pow + .nullifiers() + .contains_key(&op.get_puzzle_ticket()) + ); + + let expected_utxo = Utxo { + op_id: op.op_id(), + output_index: 0, + note: Note::new(epoch_reward, op.public_key), + }; + assert_eq!( + state + .cryptarchia_ledger + .latest_utxos() + .get(&expected_utxo.id()), + Some(expected_utxo) + ); + + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0], + TxEvent { + tx_hash: event_tx_hash, + payload: TxEventPayload::PoWRewardClaimed { .. }, + .. + } if *event_tx_hash == tx_hash + )); + } + + #[test] + fn claim_execution_alone_has_no_double_claim_guard() { + // `try_apply_op` is execute-only by design (like the other op + // arms): the double-claim check lives in + // `ClaimPowRewardOp::validate`, which `try_apply_tx` runs via + // `verify_stateful_op` before execution (see + // `claim_tx_double_claim_is_rejected`). Calling the execution + // path directly therefore pays the same solution twice — pinned + // here to document that the guard lives in validation, not + // execution. + let (mut state, config) = pow_ledger_state(1_000); + // Execution reads the anchor block's slot from the seen-block + // map (validation, skipped here, guarantees its presence), so + // record it directly. + state + .mantle_ledger + .pow + .add_seen_block_slots(claim_op().block_hash, Slot::from(0u64)); + let pool_before = state.mantle_ledger.pow.reward_pool(); + let epoch_reward = state.mantle_ledger.pow.epoch_reward(); + let op = Op::ClaimPowReward(claim_op()); + let tx_hash = TxHash::from([9u8; 32]); + + let (state, _, _) = state + .try_apply_op::( + &op, + &config, + &tx_hash, + 0, + Vec::new(), + ) + .expect("first claim should succeed"); + let (state, _, _) = state + .try_apply_op::( + &op, + &config, + &tx_hash, + 0, + Vec::new(), + ) + .expect("second claim currently also succeeds (no validation)"); + + assert_eq!( + state.mantle_ledger.pow.reward_pool(), + pool_before - 2 * epoch_reward + ); + } + + #[test] + fn block_fees_do_not_refill_the_pow_pool_while_the_share_is_zero() { + // Pins that `POW_REWARD_SHARE_NUMERATOR` is still 0: block fees + // are split between leaders and blend only, so nothing accrues + // to the PoW refill and the pool is unchanged after crediting. + let config = config(); + let mut state = LedgerState::from_utxos([utxo()], &config); + let pool_before = state.mantle_ledger.pow.reward_pool(); + + state = state + .compute_block_rewards(1_000.into(), 0.into()) + .expect("reward computation should succeed"); + + state + .mantle_ledger + .pow + .add_rewards_to_pool::(); + assert_eq!(state.mantle_ledger.pow.reward_pool(), pool_before); + } + } } diff --git a/ledger/src/mantle/helpers.rs b/ledger/src/mantle/helpers.rs index caec61f46..0b31a46e7 100644 --- a/ledger/src/mantle/helpers.rs +++ b/ledger/src/mantle/helpers.rs @@ -1,10 +1,12 @@ use lb_core::{ + crypto::Hash, mantle::{ channel::Channels, ledger::{Declarations, Utxos}, ops::{ channel::{ChannelId, ChannelKeyIndex}, leader_claim::{RewardsRoot, VoucherNullifier}, + pow::{PowNullifier, PowReward, PowTarget}, sdp::SdpError, }, transactions::{OperationVerificationHelper, VerificationError}, @@ -13,7 +15,7 @@ use lb_core::{ }; use lb_cryptarchia_engine::{Epoch, Slot}; use lb_key_management_system_keys::keys::Ed25519PublicKey; -use rpds::HashTrieSetSync; +use rpds::{HashTrieMapSync, HashTrieSetSync}; use crate::mantle::LedgerState; @@ -127,4 +129,28 @@ impl OperationVerificationHelper for MantleOperationVerificationHelper<'_> { }) .cloned() } + + fn get_pow_reward_difficulty(&self) -> PowTarget { + self.ledger_state.pow.reward_difficulty() + } + + fn get_pow_nullifiers(&self) -> &HashTrieMapSync { + self.ledger_state.pow.nullifiers() + } + + fn get_epoch_pow_reward(&self) -> PowReward { + self.ledger_state.pow.epoch_reward() + } + + fn get_pow_reward_pool(&self) -> PowReward { + self.ledger_state.pow.reward_pool() + } + + fn get_previous_epoch(&self) -> Epoch { + Epoch::from(self.get_epoch().into_inner().saturating_sub(1)) + } + + fn get_blocks_slot(&self) -> HashTrieMapSync { + self.ledger_state.pow.block_slots().clone() + } } diff --git a/ledger/src/mantle/mod.rs b/ledger/src/mantle/mod.rs index 3eee08c90..6f1fa9558 100644 --- a/ledger/src/mantle/mod.rs +++ b/ledger/src/mantle/mod.rs @@ -1,10 +1,11 @@ pub use lb_core::mantle::channel; pub mod helpers; pub mod leader; +pub mod pow; pub mod sdp; use lb_core::{ - crypto::ZkHasher, + crypto::{Hash, ZkHasher}, events::TxEvent, mantle::{ NoteId, Value, @@ -15,6 +16,7 @@ use lb_core::{ inscribe::{InscriptionExecutionContext, InscriptionOp}, }, leader_claim::{LeaderClaimError, RewardsRoot, VoucherCm}, + pow::ClaimPowRewardError, sdp::{SDPActiveOp, SDPDeclareOp, SDPWithdrawOp}, transfer::TransferError, }, @@ -43,6 +45,8 @@ pub enum Error { Transfer(#[from] TransferError), #[error(transparent)] LeaderClaim(#[from] LeaderClaimError), + #[error(transparent)] + ClaimPow(#[from] ClaimPowRewardError), #[error("Note not found: {0:?}")] NoteNotFound(NoteId), } @@ -56,6 +60,7 @@ pub struct LedgerState { channels: channel::Channels, pub sdp: sdp::SdpLedger, pub leaders: leader::LeaderState, + pub pow: pow::PowState, } impl LedgerState { @@ -66,6 +71,7 @@ impl LedgerState { sdp: sdp::SdpLedger::new(epoch_state.epoch()) .with_blend_service(&config.sdp_config.service_rewards_params.blend, epoch_state), leaders: leader::LeaderState::new(), + pow: pow::PowState::new(), } } @@ -94,6 +100,7 @@ impl LedgerState { channels, sdp, leaders: leader::LeaderState::new(), + pow: pow::PowState::new(), }, tx_events, )) @@ -148,9 +155,24 @@ impl LedgerState { self.sdp .try_apply_header(&config.sdp_config, last_epoch_state, epoch_state)?; self.sdp = new_sdp; + self.pow = self.pow.try_apply_header(last_epoch_state, epoch_state); Ok((self, effect)) } + /// Record a newly applied block among the recently seen blocks that + /// `PoW` reward claims may anchor to, pruning entries that aged out of + /// the acceptance window. + /// + /// This runs only on the canonical apply path, where the block's id is + /// known — a proposer applying the header of a block it is still + /// building has no id to record (and that block's transactions cannot + /// anchor to it anyway). + pub fn add_seen_block(&mut self, block_hash: Hash, slot: Slot) { + self.pow.add_seen_block_slots(block_hash, slot); + self.pow.prune_seen_block_slots(slot); + self.pow.prune_nullifiers_by_slots(slot); + } + pub fn try_apply_channel_inscription( mut self, inscription_op: &InscriptionOp, diff --git a/ledger/src/mantle/pow/difficulty.rs b/ledger/src/mantle/pow/difficulty.rs new file mode 100644 index 000000000..99136c649 --- /dev/null +++ b/ledger/src/mantle/pow/difficulty.rs @@ -0,0 +1,214 @@ +use lb_core::mantle::ops::pow::PowTarget; +use lb_groth16::{Field as _, fr_to_bytes}; +use num_bigint::BigUint; + +pub trait PoWDifficultyConstants { + /// Exponential moving average, a smoothed running estimate that weights + /// recent blocks most + const EMA_SMOOTHING_FACTOR: u64; + const EMA_SMOOTHING_PRECISION: u64; + const TARGET_CLAIMS_PER_BLOCK: u64; +} + +pub struct PoWDifficultySettings; + +// TODO: change settings when decided +impl PoWDifficultyConstants for PoWDifficultySettings { + const EMA_SMOOTHING_FACTOR: u64 = 9; + const EMA_SMOOTHING_PRECISION: u64 = 10; + const TARGET_CLAIMS_PER_BLOCK: u64 = 100; +} + +pub fn compute_new_reward_difficulty( + claims_accepted_in_block: u64, + current_block_reward_target: PowTarget, +) -> PowTarget { + // (P - F): the weight of the fresh observation, with q = F / P. + let observation_weight = Constants::EMA_SMOOTHING_PRECISION + .checked_sub(Constants::EMA_SMOOTHING_FACTOR) + .expect("EMA_SMOOTHING_FACTOR must not exceed EMA_SMOOTHING_PRECISION"); + + // The arithmetic happens on plain integers: `PowTarget` is a field + // element, whose division (multiplication by the modular inverse) does + // not compute a ratio. + let current_block_reward_target = + BigUint::from_bytes_le(&fr_to_bytes(¤t_block_reward_target)); + + // Per block: normalize the count by the target that produced it, then + // smooth (EMA, smoothing q ~ window N), reconstructing the previous + // estimate from the previous target (assumed calibrated to T claims): + // demand_est = (1 - q) * (claims_in_block / current_target) + // + q * (TARGET_CLAIMS_PER_BLOCK / current_target) + // The estimate is kept as a fraction: claims are astronomically smaller + // than the target, so dividing first would truncate the demand to zero. + let demand_estimate_numerator = (BigUint::from(observation_weight) * claims_accepted_in_block + + BigUint::from(Constants::EMA_SMOOTHING_FACTOR) * Constants::TARGET_CLAIMS_PER_BLOCK) + // Zero only when F == 0 (no smoothing) and the block had no claims; + // floored to avoid dividing by zero below. + .max(BigUint::from(1u8)); + let demand_estimate_denominator = + current_block_reward_target * Constants::EMA_SMOOTHING_PRECISION; + + // Set the next target so the smoothed demand yields T claims: + // new_target = TARGET_CLAIMS_PER_BLOCK / demand_est + let new_target = BigUint::from(Constants::TARGET_CLAIMS_PER_BLOCK) + * demand_estimate_denominator + / demand_estimate_numerator; + + // Cap at p - 1 (the maximum field element) so converting back into the + // field cannot reduce mod p and wrap a large target into a tiny one. + let max_target = BigUint::from_bytes_le(&fr_to_bytes(&-PowTarget::ONE)); + PowTarget::from(new_target.min(max_target)) +} + +#[cfg(test)] +mod tests { + use lb_groth16::AdditiveGroup as _; + + use super::*; + + /// `q = 9/10`, `T = 10`. + struct TestConstants; + impl PoWDifficultyConstants for TestConstants { + const EMA_SMOOTHING_FACTOR: u64 = 9; + const EMA_SMOOTHING_PRECISION: u64 = 10; + const TARGET_CLAIMS_PER_BLOCK: u64 = 10; + } + + #[test] + fn on_target_claims_leave_the_target_unchanged() { + // claims == T is the controller's fixed point. + let target = PowTarget::from(1_000u64); + assert_eq!( + compute_new_reward_difficulty::(10, target), + target + ); + } + + #[test] + fn excess_claims_harden_the_target() { + // d = 1000, c = 2T: new = 10·10·1000 / (1·20 + 9·10) = 100000/110 + // = 909 — a gentle ~10/11 step, damped by q. + assert_eq!( + compute_new_reward_difficulty::(20, PowTarget::from(1_000u64)), + PowTarget::from(909u64) + ); + } + + #[test] + fn missing_claims_ease_the_target() { + // d = 1000, c = T/2: new = 100000 / (1·5 + 9·10) = 100000/95 = 1052. + assert_eq!( + compute_new_reward_difficulty::(5, PowTarget::from(1_000u64)), + PowTarget::from(1_052u64) + ); + } + + #[test] + fn empty_block_growth_is_bounded_by_the_smoothing_factor() { + // c = 0 is the largest possible upward step: a factor of P/F = 10/9. + // new = 100000 / (9·10) = 1111. + assert_eq!( + compute_new_reward_difficulty::(0, PowTarget::from(1_000u64)), + PowTarget::from(1_111u64) + ); + } + + #[test] + fn growth_is_capped_at_the_maximum_field_element() { + // From the easiest possible target (p - 1), an empty block would + // grow past the field; the cap keeps it at p - 1 instead of letting + // the field conversion wrap it around to a tiny target. + let max_target = -PowTarget::ONE; + assert_eq!( + compute_new_reward_difficulty::(0, max_target), + max_target + ); + } + + #[test] + fn realistic_magnitude_target_stays_in_range() { + // A target around 2^250 (the realistic magnitude): the controller + // must neither truncate the demand to zero nor wrap mod p. An + // on-target block leaves it unchanged. + let target = PowTarget::from(BigUint::from(1u8) << 250); + assert_eq!( + compute_new_reward_difficulty::(10, target), + target + ); + } + + #[test] + fn claim_flood_drives_the_target_to_zero() { + // Pins current behaviour: an enormous claim count floors the target + // to zero. Zero is an absorbing state (0 stays 0 below), so whether + // this needs a floor of 1 is a design decision left open here. + assert_eq!( + compute_new_reward_difficulty::(u64::MAX, PowTarget::from(1_000u64)), + PowTarget::ZERO + ); + } + + #[test] + fn zero_target_is_absorbing() { + // Pins current behaviour: a zero target (e.g. the unset genesis + // default) stays zero forever — genesis must seed a real initial + // difficulty for the controller to operate. + assert_eq!( + compute_new_reward_difficulty::(0, PowTarget::ZERO), + PowTarget::ZERO + ); + } + + #[test] + fn no_smoothing_with_empty_block_takes_a_large_bounded_easing_step() { + // F = 0 (q = 0, no smoothing) with an empty block makes the exact + // formula divide by zero; the numerator floor turns it into a large + // but finite easing step instead: new = T·P·d = 10·10·1000. + struct NoSmoothing; + impl PoWDifficultyConstants for NoSmoothing { + const EMA_SMOOTHING_FACTOR: u64 = 0; + const EMA_SMOOTHING_PRECISION: u64 = 10; + const TARGET_CLAIMS_PER_BLOCK: u64 = 10; + } + assert_eq!( + compute_new_reward_difficulty::(0, PowTarget::from(1_000u64)), + PowTarget::from(100_000u64) + ); + } + + #[test] + fn full_smoothing_freezes_the_target() { + // F == P is q = 1: the observation has zero weight, so the target + // never moves no matter what the block contained. + struct FullSmoothing; + impl PoWDifficultyConstants for FullSmoothing { + const EMA_SMOOTHING_FACTOR: u64 = 10; + const EMA_SMOOTHING_PRECISION: u64 = 10; + const TARGET_CLAIMS_PER_BLOCK: u64 = 10; + } + let target = PowTarget::from(1_000u64); + assert_eq!( + compute_new_reward_difficulty::(0, target), + target + ); + assert_eq!( + compute_new_reward_difficulty::(1_000_000, target), + target + ); + } + + #[test] + #[should_panic(expected = "EMA_SMOOTHING_FACTOR must not exceed")] + fn smoothing_factor_above_precision_is_rejected() { + // q > 1 would make the observation weight negative; the runtime + // check turns a silent underflow into an explicit panic. + struct BrokenConstants; + impl PoWDifficultyConstants for BrokenConstants { + const EMA_SMOOTHING_FACTOR: u64 = 11; + const EMA_SMOOTHING_PRECISION: u64 = 10; + const TARGET_CLAIMS_PER_BLOCK: u64 = 10; + } + let _ = compute_new_reward_difficulty::(10, PowTarget::from(1_000u64)); + } +} diff --git a/ledger/src/mantle/pow/mod.rs b/ledger/src/mantle/pow/mod.rs new file mode 100644 index 000000000..67723339a --- /dev/null +++ b/ledger/src/mantle/pow/mod.rs @@ -0,0 +1,680 @@ +mod difficulty; + +use std::num::NonZeroU64; + +use lb_core::{ + crypto::Hash, + mantle::{ + Value, + ops::pow::{ + ClaimPoWRewardExecutionContext, PowNullifier, PowReward, PowTarget, SLOT_WINDOW, + }, + }, +}; +use lb_cryptarchia_engine::Slot; +use lb_groth16::serde::serde_fr; +use rpds::HashTrieMapSync; + +use crate::{ + EpochState, + mantle::pow::difficulty::{PoWDifficultySettings, compute_new_reward_difficulty}, +}; + +const POW_REWARD_POOL_GENESIS: PowReward = 1_000_000_000; +const POW_EPOCH_REWARD_POOL_GENESIS: PowReward = 1_000_000; + +/// `PoW` reward-claiming state of the mantle ledger. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct PowState { + /// `R_PoW`: reserve funding `PoW` rewards. Credited at each epoch boundary + /// with the `PoW` share (`beta_PoW`) of every block's reward, summed + /// over the epoch's blocks. Drained by `sigma_e` as rewards are + /// claimed. + reward_pool: PowReward, + /// `sigma_e`: reward per claim, fixed for the epoch. + epoch_reward: PowReward, + /// `d_reward`: the REWARD threshold, retargeted every block + #[serde(with = "serde_fr")] + reward_difficulty: PowTarget, + /// Rewards collected during the current epoch, added to the + /// `reward_pool` at the next epoch boundary. + refill_rewards: PowReward, + /// Spent `PoW` solutions, retained only for the acceptance + /// Values are the **claimed** slot used to trim after the validation window + /// expires. + nullifiers: HashTrieMapSync, + /// Slots of recently seen blocks by hash, retained for the + /// window-of-acceptance check and pruned as they age out of + /// [`SLOT_WINDOW`]. Keyed by the wire-format block hash — the same + /// value a `ClaimPowRewardOp` anchors to — so consensus state stays + /// independent of the node's header-id type. + block_slots: HashTrieMapSync, +} + +impl Default for PowState { + fn default() -> Self { + Self::new() + } +} + +impl PowState { + /// Create the genesis `PoW` state: pool and per-claim reward seeded from + /// the genesis endowment, initial difficulty derived from them, and no + /// claims or seen blocks yet. + #[must_use] + pub fn new() -> Self { + // TODO: Setup values when decided + Self { + reward_pool: POW_REWARD_POOL_GENESIS, + epoch_reward: POW_EPOCH_REWARD_POOL_GENESIS, + reward_difficulty: compute_new_reward_difficulty::( + 1000, + PowTarget::from(POW_EPOCH_REWARD_POOL_GENESIS), + ), + refill_rewards: 0, + nullifiers: HashTrieMapSync::new_sync(), + block_slots: HashTrieMapSync::new_sync(), + } + } + + /// `R_PoW`: current balance of the `PoW` reward pool. + #[must_use] + pub const fn reward_pool(&self) -> Value { + self.reward_pool + } + + /// `sigma_e`: reward per claim for the current epoch. + #[must_use] + pub const fn epoch_reward(&self) -> Value { + self.epoch_reward + } + + /// Nullifiers of already-claimed `PoW` solutions. + #[must_use] + pub const fn nullifiers(&self) -> &HashTrieMapSync { + &self.nullifiers + } + + /// `d_reward`: the current reward difficulty a puzzle ticket must be + /// strictly below. + #[must_use] + pub const fn reward_difficulty(&self) -> PowTarget { + self.reward_difficulty + } + + /// Apply the outcome of a [`ClaimPowRewardOp`] execution to this state. + /// + /// [`ClaimPowRewardOp`]: lb_core::mantle::ops::pow::ClaimPowRewardOp + pub fn update_from_claim_execution_result(&mut self, context: &ClaimPoWRewardExecutionContext) { + self.nullifiers = context.nullifiers.clone(); + self.reward_pool = context.reward_pool; + } + + /// Move the epoch's collected `refill_rewards` into the `reward_pool` + /// and recompute the per-claim `epoch_reward` from it. + pub(crate) fn add_rewards_to_pool(&mut self) { + self.reward_pool = self.reward_pool.saturating_add(self.refill_rewards); + self.refill_rewards = 0; + self.epoch_reward = compute_epoch_pow_reward::(self.reward_pool); + } + + /// Add `reward` to the current epoch's pending `refill_rewards`. + pub(crate) const fn add_reward_refill_rewards(&mut self, reward: PowReward) { + self.refill_rewards = self.refill_rewards.saturating_add(reward); + } + + pub(crate) fn update_difficulty(&mut self, claims_in_block: u64) { + self.reward_difficulty = compute_new_reward_difficulty::( + claims_in_block, + self.reward_difficulty, + ); + } + + /// Slots of the recently seen blocks a claim may anchor to, by hash. + #[must_use] + pub const fn block_slots(&self) -> &HashTrieMapSync { + &self.block_slots + } + + /// Record the slot of a newly applied block. + pub(crate) fn add_seen_block_slots(&mut self, block_hash: Hash, slot: Slot) { + self.block_slots.insert_mut(block_hash, slot); + } + + /// Drop seen blocks that have aged out of the acceptance window: the + /// window check rejects them regardless, so they no longer need to be + /// retained (§5.1.1). + pub(crate) fn prune_seen_block_slots(&mut self, current: Slot) { + let cutoff = current.saturating_sub(Slot::from(SLOT_WINDOW)); + self.block_slots = self + .block_slots + .into_iter() + .filter_map(|(&hash, &slot)| (slot >= cutoff).then_some((hash, slot))) + .collect(); + } + + /// Drop seen nullifiers that have aged out of the acceptance window: the + /// window check rejects them regardless, so they no longer need to be + /// retained (§5.1.1). + pub(crate) fn prune_nullifiers_by_slots(&mut self, current: Slot) { + let cutoff = current.saturating_sub(Slot::from(SLOT_WINDOW)); + self.nullifiers = self + .nullifiers + .into_iter() + .filter_map(|(&nullifier, &slot)| (slot >= cutoff).then_some((nullifier, slot))) + .collect(); + } + + /// Apply an epoch transition: on epoch change, refill the reward pool + /// from the rewards collected during `previous_epoch`. + pub(crate) fn try_apply_header( + &self, + previous_epoch: &EpochState, + next_epoch: &EpochState, + ) -> Self { + if previous_epoch.epoch >= next_epoch.epoch { + return self.clone(); + } + let mut new_self = self.clone(); + new_self.add_rewards_to_pool::(); + new_self + } +} + +#[cfg(test)] +impl PowState { + /// Test-only: seed the reward difficulty directly, standing in for the + /// genesis initial-difficulty seeding that is not implemented yet. + pub(crate) const fn set_reward_difficulty(&mut self, difficulty: PowTarget) { + self.reward_difficulty = difficulty; + } +} + +/// Network parameters controlling how much of the `PoW` reward pool is paid +/// out per epoch, expressed as the rate `RATE_NUM / denominator()`. +pub trait ClaimPoWConstants { + /// Numerator of the per-epoch payout rate. + const RATE_NUM: u64 = 0; + /// Denominator scale of the per-epoch payout rate. + const RATE_DEN: u64 = 1; + /// Expected number of reward claims per block. + const TARGET_CLAIM_PER_BLOCK: u64 = 1; + /// Expected number of blocks per epoch. + const EXPECTED_BLOCKS_PER_EPOCH: u64 = 1; + + /// Full denominator of the per-epoch payout rate. + #[must_use] + fn denominator() -> NonZeroU64 { + NonZeroU64::new( + Self::RATE_DEN * Self::TARGET_CLAIM_PER_BLOCK * Self::EXPECTED_BLOCKS_PER_EPOCH, + ) + .expect("Static values should compute a valid Denominator") + } +} + +/// [`ClaimPoWConstants`] with `PoW` claiming disabled: all rates are zero, so +/// no reward is ever paid out. +struct ClaimPoWDisabledConstants; + +impl ClaimPoWConstants for ClaimPoWDisabledConstants { + const RATE_NUM: u64 = 0; + const RATE_DEN: u64 = 1; + const TARGET_CLAIM_PER_BLOCK: u64 = 1; + const EXPECTED_BLOCKS_PER_EPOCH: u64 = 1; +} + +/// Compute the per-claim `sigma_e` reward for the epoch from the current +/// `PoW` reward pool balance, per `Constants`' payout rate. +/// +/// The intermediate product is widened to `u128` so a full pool +/// (`u64::MAX`, reachable through saturation) cannot overflow with a +/// `RATE_NUM` greater than one; a result beyond `u64` saturates. +#[must_use] +pub fn compute_epoch_pow_reward( + pow_reward_pool: PowReward, +) -> PowReward { + let denominator = u64::from(Constants::denominator()); + let reward = + u128::from(pow_reward_pool) * u128::from(Constants::RATE_NUM) / u128::from(denominator); + PowReward::try_from(reward).unwrap_or(PowReward::MAX) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use lb_core::{ + mantle::{ledger::Utxos, transactions::hash::TxHash}, + sdp::Declarations, + }; + use lb_groth16::{AdditiveGroup as _, Field as _, Fr}; + + use super::*; + use crate::UtxoTree; + + fn epoch_state(epoch: u32) -> EpochState { + EpochState { + epoch: epoch.into(), + nonce: Fr::ZERO, + utxos: UtxoTree::default(), + total_stake: 0, + lottery_0: Fr::ZERO, + lottery_1: Fr::ZERO, + active_declarations: Arc::new(Declarations::default()), + } + } + + /// A payout rate of `1/100`: `sigma_e = pool / 100`. + struct TestConstants; + impl ClaimPoWConstants for TestConstants { + const RATE_NUM: u64 = 1; + const RATE_DEN: u64 = 1; + const TARGET_CLAIM_PER_BLOCK: u64 = 10; + const EXPECTED_BLOCKS_PER_EPOCH: u64 = 10; + } + + const BLOCK_A: Hash = [1u8; 32]; + const BLOCK_B: Hash = [2u8; 32]; + + #[test] + fn new_state_starts_with_genesis_values() { + let state = PowState::new(); + assert_eq!(state.reward_pool(), POW_REWARD_POOL_GENESIS); + assert_eq!(state.epoch_reward(), POW_EPOCH_REWARD_POOL_GENESIS); + // The initial difficulty is seeded too — a zero target would be an + // absorbing state no claim could ever satisfy. + assert_ne!(state.reward_difficulty(), PowTarget::default()); + assert!(state.nullifiers().is_empty()); + assert!(state.block_slots().is_empty()); + } + + #[test] + fn compute_epoch_pow_reward_applies_rate() { + assert_eq!(compute_epoch_pow_reward::(1_000), 10); + assert_eq!(compute_epoch_pow_reward::(0), 0); + // Rounds down when the pool doesn't divide the rate evenly. + assert_eq!(compute_epoch_pow_reward::(150), 1); + assert_eq!(compute_epoch_pow_reward::(99), 0); + } + + #[test] + fn compute_epoch_pow_reward_disabled_is_always_zero() { + assert_eq!( + compute_epoch_pow_reward::(u64::MAX), + 0 + ); + } + + #[test] + fn compute_epoch_pow_reward_does_not_overflow_on_full_pool() { + // A rate with RATE_NUM > 1: `sigma_e = pool * 2 / 4`. + struct HighRateConstants; + impl ClaimPoWConstants for HighRateConstants { + const RATE_NUM: u64 = 2; + const RATE_DEN: u64 = 1; + const TARGET_CLAIM_PER_BLOCK: u64 = 1; + const EXPECTED_BLOCKS_PER_EPOCH: u64 = 4; + } + + // The pool can legitimately reach u64::MAX (it saturates there), so + // `pool * RATE_NUM` must be widened past u64 or it overflows for any + // RATE_NUM > 1. + assert_eq!( + compute_epoch_pow_reward::(u64::MAX), + u64::MAX / 2 + ); + } + + #[test] + fn add_rewards_to_pool_moves_refill_and_computes_reward() { + let mut state = PowState::new(); + state.add_reward_refill_rewards(1_000); + state.add_rewards_to_pool::(); + + assert_eq!(state.reward_pool(), POW_REWARD_POOL_GENESIS + 1_000); + assert_eq!( + state.epoch_reward(), + (POW_REWARD_POOL_GENESIS + 1_000) / 100 + ); + } + + #[test] + fn add_rewards_to_pool_accumulates_across_multiple_refills() { + let mut state = PowState::new(); + state.add_reward_refill_rewards(400); + state.add_reward_refill_rewards(600); + state.add_rewards_to_pool::(); + + assert_eq!(state.reward_pool(), POW_REWARD_POOL_GENESIS + 1_000); + } + + #[test] + fn add_rewards_to_pool_is_noop_on_pool_when_no_refill_is_pending() { + let mut state = PowState::new(); + state.add_reward_refill_rewards(1_000); + state.add_rewards_to_pool::(); + // Refill was reset by the call above: applying again must not add + // anything further to the pool. + state.add_rewards_to_pool::(); + + assert_eq!(state.reward_pool(), POW_REWARD_POOL_GENESIS + 1_000); + assert_eq!( + state.epoch_reward(), + (POW_REWARD_POOL_GENESIS + 1_000) / 100 + ); + } + + #[test] + fn add_rewards_to_pool_recomputes_reward_from_new_pool_each_time() { + let mut state = PowState::new(); + state.add_reward_refill_rewards(1_000); + state.add_rewards_to_pool::(); + assert_eq!( + state.epoch_reward(), + (POW_REWARD_POOL_GENESIS + 1_000) / 100 + ); + + state.add_reward_refill_rewards(9_000); + state.add_rewards_to_pool::(); + + assert_eq!(state.reward_pool(), POW_REWARD_POOL_GENESIS + 10_000); + assert_eq!( + state.epoch_reward(), + (POW_REWARD_POOL_GENESIS + 10_000) / 100 + ); + } + + #[test] + fn refill_rewards_saturate_instead_of_overflowing() { + let mut state = PowState::new(); + state.add_reward_refill_rewards(u64::MAX); + state.add_reward_refill_rewards(1); + state.add_rewards_to_pool::(); + + assert_eq!(state.reward_pool(), u64::MAX); + } + + #[test] + fn reward_pool_saturates_instead_of_overflowing() { + let mut state = PowState::new(); + state.add_reward_refill_rewards(u64::MAX); + state.add_rewards_to_pool::(); + state.add_reward_refill_rewards(u64::MAX); + state.add_rewards_to_pool::(); + + assert_eq!(state.reward_pool(), u64::MAX); + } + + #[test] + fn try_apply_header_is_noop_when_epoch_does_not_advance() { + let mut state = PowState::new(); + state.add_reward_refill_rewards(500); + let same_epoch = epoch_state(3); + + let unchanged = state.try_apply_header(&same_epoch, &same_epoch); + + assert_eq!(unchanged, state); + assert_eq!(unchanged.reward_pool(), POW_REWARD_POOL_GENESIS); + } + + #[test] + fn try_apply_header_is_noop_when_epoch_goes_backwards() { + let mut state = PowState::new(); + state.add_reward_refill_rewards(500); + let earlier = epoch_state(1); + let later = epoch_state(5); + + // `next_epoch` behind `previous_epoch`, e.g. a stale/reorged branch. + let unchanged = state.try_apply_header(&later, &earlier); + + assert_eq!(unchanged, state); + } + + #[test] + fn try_apply_header_does_not_mutate_the_receiver() { + let mut state = PowState::new(); + state.add_reward_refill_rewards(500); + let original = state.clone(); + let previous = epoch_state(0); + let next = epoch_state(1); + + drop(state.try_apply_header(&previous, &next)); + + assert_eq!(state, original); + } + + #[test] + fn try_apply_header_moves_pending_refill_into_pool_on_advance() { + let mut state = PowState::new(); + state.add_reward_refill_rewards(500); + let previous = epoch_state(0); + let next = epoch_state(1); + + let new_state = state.try_apply_header(&previous, &next); + + assert_eq!(new_state.reward_pool(), POW_REWARD_POOL_GENESIS + 500); + } + + #[test] + fn try_apply_header_leaves_reward_claiming_disabled() { + // `try_apply_header` currently always refills through + // `ClaimPoWDisabledConstants` (claiming isn't activated yet), so + // `epoch_reward` is zeroed at the first transition even though the + // pool is well funded. + let mut state = PowState::new(); + state.add_reward_refill_rewards(1_000_000); + let previous = epoch_state(0); + let next = epoch_state(1); + + let new_state = state.try_apply_header(&previous, &next); + + assert_eq!(new_state.reward_pool(), POW_REWARD_POOL_GENESIS + 1_000_000); + assert_eq!(new_state.epoch_reward(), 0); + } + + #[test] + fn try_apply_header_across_multiple_epoch_jump_applies_once() { + let mut state = PowState::new(); + state.add_reward_refill_rewards(500); + let previous = epoch_state(0); + let next = epoch_state(5); + + let new_state = state.try_apply_header(&previous, &next); + + assert_eq!(new_state.reward_pool(), POW_REWARD_POOL_GENESIS + 500); + } + + #[test] + fn try_apply_header_preserves_pending_refill_across_noop_transitions() { + // A no-op transition (same epoch) must not drop a refill that + // hasn't been credited to the pool yet. + let mut state = PowState::new(); + state.add_reward_refill_rewards(200); + let same = epoch_state(2); + let mut state = state.try_apply_header(&same, &same); + + state.add_reward_refill_rewards(300); + let previous = epoch_state(2); + let next = epoch_state(3); + let new_state = state.try_apply_header(&previous, &next); + + assert_eq!(new_state.reward_pool(), POW_REWARD_POOL_GENESIS + 500); + } + + #[test] + fn seen_block_slots_are_pruned_once_they_age_out_of_the_window() { + let mut state = PowState::new(); + + // Block A at slot 5, then block B exactly SLOT_WINDOW later: A sits + // right on the cutoff (`current - WINDOW`) and must survive, since + // the window check still accepts a gap equal to the window. + state.add_seen_block_slots(BLOCK_A, Slot::from(5u64)); + state.add_seen_block_slots(BLOCK_B, Slot::from(5 + SLOT_WINDOW)); + state.prune_seen_block_slots(Slot::from(5 + SLOT_WINDOW)); + assert!(state.block_slots().contains_key(&BLOCK_A)); + assert!(state.block_slots().contains_key(&BLOCK_B)); + + // One slot further, A is strictly older than the window and is + // pruned; B remains. + state.prune_seen_block_slots(Slot::from(5 + SLOT_WINDOW + 1)); + assert!(!state.block_slots().contains_key(&BLOCK_A)); + assert!(state.block_slots().contains_key(&BLOCK_B)); + } + + #[test] + fn nullifiers_are_pruned_once_they_age_out_of_the_window() { + // Spent solutions are retained only for `SLOT_WINDOW`: once their + // claim slot ages out, the window check rejects any reuse anyway, so + // the nullifier can be dropped (§5.1.1). + let old_nullifier = PowNullifier::from(Fr::ONE); + let recent_nullifier = PowNullifier::from(Fr::from(2u64)); + let nullifiers = HashTrieMapSync::new_sync() + .insert(old_nullifier, Slot::from(5u64)) + .insert(recent_nullifier, Slot::from(5 + SLOT_WINDOW)); + let mut state = PowState::new(); + state.update_from_claim_execution_result(&ClaimPoWRewardExecutionContext { + reward_pool: state.reward_pool(), + epoch_reward: 0, + nullifiers, + tx_hash: TxHash::from([7u8; 32]), + utxos: Utxos::new(), + block_slots: HashTrieMapSync::new_sync(), + }); + + // A claim slot exactly `SLOT_WINDOW` back sits on the cutoff and must + // survive, matching the inclusive window check. + state.prune_nullifiers_by_slots(Slot::from(5 + SLOT_WINDOW)); + assert!(state.nullifiers().contains_key(&old_nullifier)); + assert!(state.nullifiers().contains_key(&recent_nullifier)); + + // One slot further, the old nullifier is strictly outside the window + // and is dropped; the recent one remains. + state.prune_nullifiers_by_slots(Slot::from(5 + SLOT_WINDOW + 1)); + assert!(!state.nullifiers().contains_key(&old_nullifier)); + assert!(state.nullifiers().contains_key(&recent_nullifier)); + } + + #[test] + fn update_from_claim_execution_result_replaces_pool_and_nullifiers() { + let mut state = PowState::new(); + state.add_reward_refill_rewards(1_000); + state.add_rewards_to_pool::(); + let epoch_reward = (POW_REWARD_POOL_GENESIS + 1_000) / 100; + assert_eq!(state.reward_pool(), POW_REWARD_POOL_GENESIS + 1_000); + assert_eq!(state.epoch_reward(), epoch_reward); + + let nullifier = PowNullifier::from(Fr::ONE); + let nullifiers = HashTrieMapSync::new_sync().insert(nullifier, Slot::from(7u64)); + let context = ClaimPoWRewardExecutionContext { + reward_pool: 990, + epoch_reward, + nullifiers: nullifiers.clone(), + tx_hash: TxHash::from([7u8; 32]), + utxos: Utxos::new(), + block_slots: HashTrieMapSync::new_sync(), + }; + + state.update_from_claim_execution_result(&context); + + assert_eq!(state.reward_pool(), 990); + assert_eq!(state.nullifiers(), &nullifiers); + assert!(state.nullifiers().contains_key(&nullifier)); + // Unrelated fields are left untouched by this update. + assert_eq!(state.epoch_reward(), epoch_reward); + } + + /// Build a claim execution result that drains the pool to `reward_pool`, + /// recording `nullifier` as spent. + fn claim_result( + reward_pool: PowReward, + nullifier: PowNullifier, + ) -> ClaimPoWRewardExecutionContext { + ClaimPoWRewardExecutionContext { + reward_pool, + epoch_reward: 0, + nullifiers: HashTrieMapSync::new_sync().insert(nullifier, Slot::from(7u64)), + tx_hash: TxHash::from([7u8; 32]), + utxos: Utxos::new(), + block_slots: HashTrieMapSync::new_sync(), + } + } + + #[test] + fn epoch_reward_tapers_as_claims_drain_the_pool() { + // Spec §5.6: sigma_e is recomputed at each boundary from the pool as + // claims left it, so a drained pool pays a smaller per-claim reward + // in the next epoch, tapering to zero (the safety cutoff's input). + let mut state = PowState::new(); + state.add_reward_refill_rewards(1_000); + state.add_rewards_to_pool::(); + assert_eq!( + state.epoch_reward(), + (POW_REWARD_POOL_GENESIS + 1_000) / 100 + ); + + // Claims drain the pool down to 990. + state.update_from_claim_execution_result(&claim_result(990, PowNullifier::from(Fr::ONE))); + state.add_rewards_to_pool::(); + assert_eq!(state.epoch_reward(), 9); + + // Drained below the payout rate, sigma_e floors to zero and the + // safety cutoff (§5.6 `pow_reward_enabled`) would disable claiming. + state.update_from_claim_execution_result(&claim_result(99, PowNullifier::from(Fr::ONE))); + state.add_rewards_to_pool::(); + assert_eq!(state.epoch_reward(), 0); + } + + #[test] + fn claim_execution_result_does_not_clobber_pending_refill() { + // Spec §5.8: within an epoch the spendable pool is touched only by + // claim draws; the refill accrues on the side and lands whole at the + // boundary. A claim applied after refills have accrued must not + // discard them. + let mut state = PowState::new(); + state.add_reward_refill_rewards(1_000); + state.add_rewards_to_pool::(); + + // Mid-epoch: block rewards accrue, then a claim drains the pool. + state.add_reward_refill_rewards(500); + state.update_from_claim_execution_result(&claim_result(990, PowNullifier::from(Fr::ONE))); + + // Boundary: the refill is credited on top of the post-claim pool, + // and sigma_e is snapshotted from the refilled pool (§5.6 ordering). + state.add_rewards_to_pool::(); + assert_eq!(state.reward_pool(), 1_490); + assert_eq!(state.epoch_reward(), 14); + } + + #[test] + fn try_apply_header_carries_nullifiers_forward() { + // Spec §5.5/§5.1.1: spent solutions must stay rejected while their + // block_hash is inside the acceptance window, which spans epoch + // boundaries. Nullifier pruning by window age is not implemented + // yet, so today the whole set must survive a transition untouched. + let nullifier = PowNullifier::from(Fr::ONE); + let mut state = PowState::new(); + state.update_from_claim_execution_result(&claim_result(0, nullifier)); + + let new_state = state.try_apply_header(&epoch_state(0), &epoch_state(1)); + + assert!(new_state.nullifiers().contains_key(&nullifier)); + } + + #[test] + fn pow_state_serde_round_trip() { + // PowState is consensus state carried per block; `reward_difficulty` + // serializes through the custom `serde_fr` codec and the nullifier + // set through rpds. A round trip must reproduce the state exactly, + // including a pending (not yet credited) refill. + let mut state = PowState::new(); + state.add_reward_refill_rewards(1_000); + state.add_rewards_to_pool::(); + state.update_from_claim_execution_result(&claim_result(990, PowNullifier::from(Fr::ONE))); + state.add_reward_refill_rewards(123); + + let json = serde_json::to_string(&state).expect("PowState should serialize"); + let restored: PowState = serde_json::from_str(&json).expect("PowState should deserialize"); + + assert_eq!(restored, state); + } +} diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index a6e6be5c4..39721c979 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -98,6 +98,8 @@ pub enum WalletOp { Lock(NoteId), /// Create the reward note. LeaderClaim(Utxo), + /// Create the reward note. + ClaimPoW(Utxo), /// Drop the deposited notes from the wallet and insert the channel notes /// they are re-created as. The re-created notes keep the same key, so they /// remain eligible for `PoL`, but are gated out of wallet-driven spending. @@ -138,7 +140,9 @@ impl WalletBlock { WalletOp::ChannelDeposit(op) => op.inputs.iter().copied().collect::>(), WalletOp::ChannelTransfer(op) => op.inputs.iter().copied().collect::>(), WalletOp::Lock(note_id) => vec![*note_id], - WalletOp::ChannelWithdraw(_) | WalletOp::LeaderClaim(_) => Vec::new(), + WalletOp::ChannelWithdraw(_) | WalletOp::LeaderClaim(_) | WalletOp::ClaimPoW(_) => { + Vec::new() + } }) .collect() } @@ -417,7 +421,7 @@ impl WalletState { locked_notes.insert_mut(*note_id); } } - WalletOp::LeaderClaim(utxo) => { + WalletOp::LeaderClaim(utxo) | WalletOp::ClaimPoW(utxo) => { insert_utxo_if_owned(*utxo, known_keys, &mut utxos, &mut pk_index); } } @@ -595,6 +599,7 @@ fn transform_op(op: &Op, event: Option) -> Option { TxEventPayload::Deposit { .. } => { panic!("event for LeaderClaim op must be LeaderRewardClaimed") } + TxEventPayload::PoWRewardClaimed { utxo, .. } => Some(WalletOp::ClaimPoW(utxo)), }, Op::ClaimPowReward(_) => { // TODO: something to track here?