feat(pow): Empowering Mantle (#3248)

This commit is contained in:
Daniel Sanchez
2026-08-07 12:37:25 +00:00
committed by GitHub
parent cd976a0545
commit 6dcbd6f202
14 changed files with 2030 additions and 53 deletions
Generated
+1
View File
@@ -4719,6 +4719,7 @@ dependencies = [
"rpds",
"serde",
"serde_arrays",
"serde_json",
"thiserror 2.0.18",
"tracing",
]
+5
View File
@@ -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> {
self.0.checked_sub(rhs.0).map(Self)
}
}
impl From<u32> for Epoch {
+6
View File
@@ -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
+465 -40
View File
@@ -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<ZkHash> for PowNullifier {
@@ -47,15 +55,27 @@ impl From<PowNullifier> 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<PowNullifier>,
/// Nullifiers of already-claimed `PoW` solutions, mapped to the slot
/// they were claimed at.
pub pow_nullifiers: &'a HashTrieMapSync<PowNullifier, Slot>,
// 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<Hash, u64>,
/// 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<Hash, Slot>,
}
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<const WINDOW: u64>(
&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<u8> {
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<PowNullifier, Slot>,
/// 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<Hash, Slot>,
}
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<verification_mode::StandardMode> 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<TxEvent>), 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<PowNullifier, Slot>,
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<PowNullifier, Slot>,
) -> 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(),
}));
}
}
+2
View File
@@ -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),
}
@@ -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<Unverified> {
(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<Preverified> {
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<Preverified> invariant: Op/Proof pairs have been verified in
// preverify, so this branch should be unreachable.
_ => {
@@ -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<Ed25519PublicKey, VerificationError>;
// `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<PowNullifier, Slot>;
/// `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<Hash, Slot>;
}
#[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<VoucherNullifier>,
claimable_vouchers_root: RewardsRoot,
pow_reward_difficulty: PowTarget,
pow_nullifiers: HashTrieMapSync<PowNullifier, Slot>,
epoch_pow_reward: PowReward,
pow_reward_pool: PowReward,
previous_epoch: Epoch,
blocks_slot: HashTrieMapSync<Hash, Slot>,
}
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<PowNullifier, Slot>,
) -> 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<Item = (Hash, Slot)>,
) -> 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<PowNullifier, Slot> {
&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<Hash, Slot> {
self.blocks_slot.clone()
}
}
}
+2 -1
View File
@@ -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"]
+461 -7
View File
@@ -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<Id> Ledger<Id>
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<BlockHash>,
{
// 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<Context = GasPrices> + 'tx,
LeaderProof: leader_proof::LeaderProof,
Constants: GasConstants,
Id: Into<BlockHash>,
{
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<Item = &'tx Tx>,
@@ -219,9 +237,24 @@ impl LedgerState {
Tx: PreverifiedMantleTx<Context = GasPrices> + 'tx,
LeaderProof: leader_proof::LeaderProof,
Constants: GasConstants,
Id: Into<BlockHash>,
{
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::<TestPoolConstants>();
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<HeaderId>, 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<Preverified> {
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::<DisabledConstants>();
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::<HeaderId, MainnetGasConstants>(
&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::<HeaderId, MainnetGasConstants>(
&op,
&config,
&tx_hash,
0,
Vec::new(),
)
.expect("first claim should succeed");
let (state, _, _) = state
.try_apply_op::<HeaderId, MainnetGasConstants>(
&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::<TestPoolConstants>();
assert_eq!(state.mantle_ledger.pow.reward_pool(), pool_before);
}
}
}
+27 -1
View File
@@ -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<PowNullifier, Slot> {
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<Hash, Slot> {
self.ledger_state.pow.block_slots().clone()
}
}
+23 -1
View File
@@ -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,
+214
View File
@@ -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<Constants: PoWDifficultyConstants>(
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(&current_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::<TestConstants>(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::<TestConstants>(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::<TestConstants>(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::<TestConstants>(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::<TestConstants>(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::<TestConstants>(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::<TestConstants>(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::<TestConstants>(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::<NoSmoothing>(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::<FullSmoothing>(0, target),
target
);
assert_eq!(
compute_new_reward_difficulty::<FullSmoothing>(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::<BrokenConstants>(10, PowTarget::from(1_000u64));
}
}
+680
View File
@@ -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<PowNullifier, Slot>,
/// 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<Hash, Slot>,
}
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::<PoWDifficultySettings>(
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<PowNullifier, Slot> {
&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<Constants: ClaimPoWConstants>(&mut self) {
self.reward_pool = self.reward_pool.saturating_add(self.refill_rewards);
self.refill_rewards = 0;
self.epoch_reward = compute_epoch_pow_reward::<Constants>(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::<PoWDifficultySettings>(
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<Hash, Slot> {
&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::<ClaimPoWDisabledConstants>();
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<Constants: ClaimPoWConstants>(
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::<TestConstants>(1_000), 10);
assert_eq!(compute_epoch_pow_reward::<TestConstants>(0), 0);
// Rounds down when the pool doesn't divide the rate evenly.
assert_eq!(compute_epoch_pow_reward::<TestConstants>(150), 1);
assert_eq!(compute_epoch_pow_reward::<TestConstants>(99), 0);
}
#[test]
fn compute_epoch_pow_reward_disabled_is_always_zero() {
assert_eq!(
compute_epoch_pow_reward::<ClaimPoWDisabledConstants>(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::<HighRateConstants>(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::<TestConstants>();
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::<TestConstants>();
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::<TestConstants>();
// Refill was reset by the call above: applying again must not add
// anything further to the pool.
state.add_rewards_to_pool::<TestConstants>();
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::<TestConstants>();
assert_eq!(
state.epoch_reward(),
(POW_REWARD_POOL_GENESIS + 1_000) / 100
);
state.add_reward_refill_rewards(9_000);
state.add_rewards_to_pool::<TestConstants>();
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::<TestConstants>();
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::<TestConstants>();
state.add_reward_refill_rewards(u64::MAX);
state.add_rewards_to_pool::<TestConstants>();
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::<TestConstants>();
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::<TestConstants>();
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::<TestConstants>();
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::<TestConstants>();
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::<TestConstants>();
// 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::<TestConstants>();
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::<TestConstants>();
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);
}
}
+7 -2
View File
@@ -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::<Vec<_>>(),
WalletOp::ChannelTransfer(op) => op.inputs.iter().copied().collect::<Vec<_>>(),
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<TxEventPayload>) -> Option<WalletOp> {
TxEventPayload::Deposit { .. } => {
panic!("event for LeaderClaim op must be LeaderRewardClaimed")
}
TxEventPayload::PoWRewardClaimed { utxo, .. } => Some(WalletOp::ClaimPoW(utxo)),
},
Op::ClaimPowReward(_) => {
// TODO: something to track here?