mirror of
https://github.com/logos-co/nomos-node.git
synced 2026-08-27 09:31:10 +00:00
feat(pow): Thread in pow constants as deployment settings (#3400)
This commit is contained in:
+22
-13
@@ -1,3 +1,5 @@
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
use ark_ff::Zero as _;
|
||||
use lb_codec::{BinaryCodec, BinaryEncode as _};
|
||||
use lb_cryptarchia_engine::Slot;
|
||||
@@ -22,7 +24,6 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -149,6 +150,9 @@ pub struct ClaimPoWRewardVerificationContext<'a> {
|
||||
/// Slots of known blocks, used to check the claim's block is within
|
||||
/// the acceptance window.
|
||||
pub blocks_slot: HashTrieMapSync<Hash, Slot>,
|
||||
/// Acceptance window, in slots: how far back a claim's anchor block may
|
||||
/// be from the current block. Configured per-deployment.
|
||||
pub slot_window: NonZeroU64,
|
||||
}
|
||||
|
||||
impl ClaimPoWRewardVerificationContext<'_> {
|
||||
@@ -162,11 +166,9 @@ impl ClaimPoWRewardVerificationContext<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// On-chain `block_hash` window check, measured in slots.
|
||||
pub fn accept_claim<const WINDOW: u64>(
|
||||
&self,
|
||||
block_id: Hash,
|
||||
) -> Result<(), ClaimPowRewardError> {
|
||||
/// On-chain `block_hash` window check, measured in slots. The window is
|
||||
/// taken from [`Self::slot_window`], configured per-deployment.
|
||||
pub fn accept_claim(&self, block_id: Hash) -> Result<(), ClaimPowRewardError> {
|
||||
let Some(&block_slot) = self.blocks_slot.get(&block_id) else {
|
||||
return Err(ClaimPowRewardError::MissingBlock { block_id });
|
||||
};
|
||||
@@ -177,7 +179,7 @@ impl ClaimPoWRewardVerificationContext<'_> {
|
||||
current_slot: self.current_block_slot,
|
||||
});
|
||||
};
|
||||
if slot_gap > Slot::from(WINDOW) {
|
||||
if slot_gap > Slot::from(self.slot_window.get()) {
|
||||
return Err(ClaimPowRewardError::OutOfWindowSlot {
|
||||
slot: block_slot,
|
||||
current_slot: self.current_block_slot,
|
||||
@@ -300,7 +302,7 @@ impl VerifiableOperation<verification_mode::StandardMode> for ClaimPowRewardOp {
|
||||
context: &Self::Context<'_>,
|
||||
) -> Result<Option<DeferredZkpVerification>, Self::Error> {
|
||||
context.are_pow_reward_enabled()?;
|
||||
context.accept_claim::<{ SLOT_WINDOW }>(self.block_hash)?;
|
||||
context.accept_claim(self.block_hash)?;
|
||||
context.validate_current_epoch_nonce(self.epoch_nonce)?;
|
||||
let puzzle_ticket = self.get_puzzle_ticket();
|
||||
context.validate_difficulty_reward(puzzle_ticket)?;
|
||||
@@ -353,10 +355,14 @@ impl ExecutableOperation for ClaimPowRewardOp {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::num::NonZero;
|
||||
|
||||
use lb_groth16::{AdditiveGroup as _, Field as _};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub const SLOT_WINDOW: NonZeroU64 = NonZeroU64::new(100).expect("100 is not 0");
|
||||
|
||||
fn validation_context(
|
||||
nullifiers: &HashTrieMapSync<PowNullifier, Slot>,
|
||||
epoch_pow_reward: PowReward,
|
||||
@@ -371,6 +377,7 @@ mod tests {
|
||||
current_epoch_nonce: nonce_for_epoch(0),
|
||||
previous_epoch_nonce: nonce_for_epoch(0),
|
||||
blocks_slot: HashTrieMapSync::new_sync(),
|
||||
slot_window: SLOT_WINDOW,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +449,8 @@ mod tests {
|
||||
current_epoch_nonce: nonce_for_epoch(CURRENT_EPOCH),
|
||||
previous_epoch_nonce: nonce_for_epoch(PREVIOUS_EPOCH),
|
||||
blocks_slot: std::iter::once((CLAIM_BLOCK_HASH, Slot::from(45u64))).collect(),
|
||||
// Tests below exercise a window of 10 slots.
|
||||
slot_window: NonZero::new(10).expect("10 is not 0"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,13 +486,13 @@ mod tests {
|
||||
// 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(()));
|
||||
assert_eq!(ctx.accept_claim(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(()));
|
||||
assert_eq!(ctx.accept_claim(CLAIM_BLOCK_HASH), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -492,7 +501,7 @@ mod tests {
|
||||
let ctx = accepting_context(&nullifiers);
|
||||
let unknown = [9u8; 32];
|
||||
assert_eq!(
|
||||
ctx.accept_claim::<10>(unknown),
|
||||
ctx.accept_claim(unknown),
|
||||
Err(ClaimPowRewardError::MissingBlock { block_id: unknown })
|
||||
);
|
||||
}
|
||||
@@ -505,7 +514,7 @@ mod tests {
|
||||
ctx.blocks_slot
|
||||
.insert_mut(CLAIM_BLOCK_HASH, Slot::from(39u64));
|
||||
assert_eq!(
|
||||
ctx.accept_claim::<10>(CLAIM_BLOCK_HASH),
|
||||
ctx.accept_claim(CLAIM_BLOCK_HASH),
|
||||
Err(ClaimPowRewardError::OutOfWindowSlot {
|
||||
slot: Slot::from(39u64),
|
||||
current_slot: Slot::from(50),
|
||||
@@ -522,7 +531,7 @@ mod tests {
|
||||
ctx.blocks_slot
|
||||
.insert_mut(CLAIM_BLOCK_HASH, Slot::from(51u64));
|
||||
assert_eq!(
|
||||
ctx.accept_claim::<10>(CLAIM_BLOCK_HASH),
|
||||
ctx.accept_claim(CLAIM_BLOCK_HASH),
|
||||
Err(ClaimPowRewardError::OutOfWindowSlot {
|
||||
slot: Slot::from(51u64),
|
||||
current_slot: Slot::from(50),
|
||||
|
||||
@@ -349,6 +349,7 @@ impl SignedMantleTx<Preverified> {
|
||||
current_epoch_nonce: helper.get_current_epoch_nonce(),
|
||||
previous_epoch_nonce: helper.get_previous_epoch_nonce(),
|
||||
blocks_slot: helper.get_blocks_slot(),
|
||||
slot_window: helper.get_pow_slot_window(),
|
||||
};
|
||||
claim_pow_op
|
||||
.verify(proof, &context)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
use lb_cryptarchia_engine::{Epoch, Slot};
|
||||
use lb_key_management_system_keys::keys::Ed25519PublicKey;
|
||||
use rpds::HashTrieMapSync;
|
||||
@@ -85,11 +87,15 @@ pub trait OperationVerificationHelper {
|
||||
/// 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>;
|
||||
|
||||
/// Acceptance window, in slots, for the window-of-acceptance check.
|
||||
/// Configured per-deployment.
|
||||
fn get_pow_slot_window(&self) -> NonZeroU64;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod test_utils {
|
||||
use std::collections::HashMap;
|
||||
use std::{collections::HashMap, num::NonZeroU64};
|
||||
|
||||
use lb_cryptarchia_engine::{Epoch, Slot};
|
||||
use rpds::{HashTrieMapSync, HashTrieSetSync};
|
||||
@@ -128,6 +134,7 @@ pub mod test_utils {
|
||||
current_epoch_nonce: ZkHash,
|
||||
previous_epoch_nonce: ZkHash,
|
||||
blocks_slot: HashTrieMapSync<Hash, Slot>,
|
||||
pow_slot_window: NonZeroU64,
|
||||
}
|
||||
|
||||
impl TestOperationVerificationHelper {
|
||||
@@ -157,6 +164,7 @@ pub mod test_utils {
|
||||
current_epoch_nonce: ZkHash::default(),
|
||||
previous_epoch_nonce: ZkHash::default(),
|
||||
blocks_slot: HashTrieMapSync::new_sync(),
|
||||
pow_slot_window: NonZeroU64::new(100).expect("100 is not 0"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +229,12 @@ pub mod test_utils {
|
||||
self.blocks_slot = blocks_slot.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_pow_slot_window(mut self, slot_window: NonZeroU64) -> Self {
|
||||
self.pow_slot_window = slot_window;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl OperationVerificationHelper for TestOperationVerificationHelper {
|
||||
@@ -323,5 +337,9 @@ pub mod test_utils {
|
||||
fn get_blocks_slot(&self) -> HashTrieMapSync<Hash, Slot> {
|
||||
self.blocks_slot.clone()
|
||||
}
|
||||
|
||||
fn get_pow_slot_window(&self) -> NonZeroU64 {
|
||||
self.pow_slot_window
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,21 @@ cryptarchia:
|
||||
max_step: 2
|
||||
damping_num: 1
|
||||
damping_den_offset: 1
|
||||
reward:
|
||||
# Token-reward PoW parameters. Payout is disabled by default
|
||||
# (rate_num = 0), matching the pre-configurable behaviour.
|
||||
reward_pool_genesis: 1000000000
|
||||
epoch_reward_genesis: 1000000
|
||||
initial_difficulty_seed: 1000
|
||||
ema_smoothing_factor: 9
|
||||
ema_smoothing_precision: 10
|
||||
target_claims_per_block: 100
|
||||
rate_num: 0
|
||||
rate_den: 1
|
||||
target_claim_per_block: 1
|
||||
expected_blocks_per_epoch: 1
|
||||
# Acceptance window in slots; must match the mining service.
|
||||
slot_window: 100
|
||||
sdp_config:
|
||||
service_params:
|
||||
BN:
|
||||
|
||||
@@ -40,6 +40,21 @@ cryptarchia:
|
||||
max_step: 2
|
||||
damping_num: 1
|
||||
damping_den_offset: 1
|
||||
reward:
|
||||
# Token-reward PoW parameters. Payout is disabled by default
|
||||
# (rate_num = 0), matching the pre-configurable behaviour.
|
||||
reward_pool_genesis: 1000000000
|
||||
epoch_reward_genesis: 1000000
|
||||
initial_difficulty_seed: 1000
|
||||
ema_smoothing_factor: 9
|
||||
ema_smoothing_precision: 10
|
||||
target_claims_per_block: 100
|
||||
rate_num: 0
|
||||
rate_den: 1
|
||||
target_claim_per_block: 1
|
||||
expected_blocks_per_epoch: 1
|
||||
# Acceptance window in slots; must match the mining service.
|
||||
slot_window: 100
|
||||
sdp_config:
|
||||
service_params:
|
||||
BN:
|
||||
|
||||
@@ -40,6 +40,21 @@ cryptarchia:
|
||||
max_step: 2
|
||||
damping_num: 1
|
||||
damping_den_offset: 1
|
||||
reward:
|
||||
# Token-reward PoW parameters. Payout is disabled by default
|
||||
# (rate_num = 0), matching the pre-configurable behaviour.
|
||||
reward_pool_genesis: 1000000000
|
||||
epoch_reward_genesis: 1000000
|
||||
initial_difficulty_seed: 1000
|
||||
ema_smoothing_factor: 9
|
||||
ema_smoothing_precision: 10
|
||||
target_claims_per_block: 100
|
||||
rate_num: 0
|
||||
rate_den: 1
|
||||
target_claim_per_block: 1
|
||||
expected_blocks_per_epoch: 1
|
||||
# Acceptance window in slots; must match the mining service.
|
||||
slot_window: 100
|
||||
sdp_config:
|
||||
service_params:
|
||||
BN:
|
||||
|
||||
+246
-3
@@ -1,6 +1,7 @@
|
||||
use core::num::NonZeroU32;
|
||||
use std::num::{NonZero, NonZeroU64};
|
||||
|
||||
use lb_core::mantle::ops::pow::PowReward;
|
||||
use lb_cryptarchia_engine::{Epoch, Slot};
|
||||
pub use lb_groth16::ModulusShift;
|
||||
use lb_key_management_system_keys::keys::ZkPublicKey;
|
||||
@@ -96,10 +97,162 @@ impl Config {
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
// TODO: Add reward difficulty parameters here. For now only the ones used for
|
||||
// Blend are included.
|
||||
pub struct PoWConfig {
|
||||
pub blend: BlendPoWConfig,
|
||||
pub reward: RewardPoWConfig,
|
||||
}
|
||||
|
||||
/// Deployment-configurable parameters for the token-reward `PoW` role.
|
||||
///
|
||||
/// Covers the genesis endowment, the reward-difficulty (`d_reward`) EMA
|
||||
/// controller, the per-epoch payout rate, and the claim acceptance window.
|
||||
/// There is deliberately no `Default`: every value must be supplied by the
|
||||
/// deployment configuration. The shipped deployments set `rate_num = 0`, which
|
||||
/// disables claiming (matching the network behaviour before these values were
|
||||
/// configurable).
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
// Validate the deployment-controlled invariants on deserialization, so an
|
||||
// invalid config is rejected at load time (see [`RewardPoWConfig::validate`])
|
||||
// instead of panicking later while processing consensus state.
|
||||
#[serde(try_from = "RewardPoWConfigFields")]
|
||||
pub struct RewardPoWConfig {
|
||||
/// `R_PoW` genesis: initial balance of the reward pool.
|
||||
pub reward_pool_genesis: PowReward,
|
||||
/// `sigma_e` genesis: initial per-claim reward, also the target the
|
||||
/// initial `d_reward` is seeded from.
|
||||
pub epoch_reward_genesis: PowReward,
|
||||
/// Claim count fed to the difficulty controller to seed the initial
|
||||
/// `d_reward` at genesis.
|
||||
pub initial_difficulty_seed: u64,
|
||||
/// EMA smoothing factor `F` (weight of the prior estimate). Must not
|
||||
/// exceed [`Self::ema_smoothing_precision`].
|
||||
pub ema_smoothing_factor: u64,
|
||||
/// EMA smoothing precision `P`; the smoothing fraction is `F / P`.
|
||||
pub ema_smoothing_precision: NonZeroU64,
|
||||
/// Target reward claims per block the controller aims for.
|
||||
pub target_claims_per_block: u64,
|
||||
/// Numerator of the per-epoch payout rate. `0` disables claiming.
|
||||
pub rate_num: u64,
|
||||
/// Denominator scale of the per-epoch payout rate.
|
||||
pub rate_den: NonZeroU64,
|
||||
/// Expected number of reward claims per block, a factor of the payout-rate
|
||||
/// denominator.
|
||||
pub target_claim_per_block: NonZeroU64,
|
||||
/// Expected number of blocks per epoch, a factor of the payout-rate
|
||||
/// denominator.
|
||||
pub expected_blocks_per_epoch: NonZeroU64,
|
||||
/// Acceptance window, in slots: how far back a claim's anchor block (and
|
||||
/// its nullifier) may be from the current block.
|
||||
pub slot_window: NonZeroU64,
|
||||
}
|
||||
|
||||
/// Wire representation of [`RewardPoWConfig`], deserialized first so its
|
||||
/// invariants can be checked before a validated [`RewardPoWConfig`] is built.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RewardPoWConfigFields {
|
||||
reward_pool_genesis: PowReward,
|
||||
epoch_reward_genesis: PowReward,
|
||||
initial_difficulty_seed: u64,
|
||||
ema_smoothing_factor: u64,
|
||||
ema_smoothing_precision: NonZeroU64,
|
||||
target_claims_per_block: u64,
|
||||
rate_num: u64,
|
||||
rate_den: NonZeroU64,
|
||||
target_claim_per_block: NonZeroU64,
|
||||
expected_blocks_per_epoch: NonZeroU64,
|
||||
slot_window: NonZeroU64,
|
||||
}
|
||||
|
||||
impl TryFrom<RewardPoWConfigFields> for RewardPoWConfig {
|
||||
type Error = RewardPoWConfigError;
|
||||
|
||||
fn try_from(fields: RewardPoWConfigFields) -> Result<Self, Self::Error> {
|
||||
let config = Self {
|
||||
reward_pool_genesis: fields.reward_pool_genesis,
|
||||
epoch_reward_genesis: fields.epoch_reward_genesis,
|
||||
initial_difficulty_seed: fields.initial_difficulty_seed,
|
||||
ema_smoothing_factor: fields.ema_smoothing_factor,
|
||||
ema_smoothing_precision: fields.ema_smoothing_precision,
|
||||
target_claims_per_block: fields.target_claims_per_block,
|
||||
rate_num: fields.rate_num,
|
||||
rate_den: fields.rate_den,
|
||||
target_claim_per_block: fields.target_claim_per_block,
|
||||
expected_blocks_per_epoch: fields.expected_blocks_per_epoch,
|
||||
slot_window: fields.slot_window,
|
||||
};
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Invariant violations in a [`RewardPoWConfig`], surfaced at config-load time.
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
|
||||
pub enum RewardPoWConfigError {
|
||||
#[error(
|
||||
"EMA smoothing factor ({factor}) must not exceed EMA smoothing precision ({precision})"
|
||||
)]
|
||||
EmaSmoothingFactorExceedsPrecision { factor: u64, precision: NonZeroU64 },
|
||||
#[error(
|
||||
"claim rate denominator overflows u64: rate_den ({rate_den}) * \
|
||||
target_claim_per_block ({target_claim_per_block}) * \
|
||||
expected_blocks_per_epoch ({expected_blocks_per_epoch})"
|
||||
)]
|
||||
ClaimRateDenominatorOverflow {
|
||||
rate_den: NonZeroU64,
|
||||
target_claim_per_block: NonZeroU64,
|
||||
expected_blocks_per_epoch: NonZeroU64,
|
||||
},
|
||||
}
|
||||
|
||||
impl RewardPoWConfig {
|
||||
/// Check the invariants the reward-difficulty controller and payout-rate
|
||||
/// arithmetic rely on. Run on deserialization (see the `try_from` on this
|
||||
/// type) so a bad deployment config is rejected when it is loaded rather
|
||||
/// than panicking later while initializing or processing consensus state.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RewardPoWConfigError`] if the EMA smoothing factor exceeds the
|
||||
/// precision, or the payout-rate denominator overflows `u64`.
|
||||
pub fn validate(&self) -> Result<(), RewardPoWConfigError> {
|
||||
if self.ema_smoothing_factor > self.ema_smoothing_precision.get() {
|
||||
return Err(RewardPoWConfigError::EmaSmoothingFactorExceedsPrecision {
|
||||
factor: self.ema_smoothing_factor,
|
||||
precision: self.ema_smoothing_precision,
|
||||
});
|
||||
}
|
||||
// Discard the value; this call only checks that it does not overflow.
|
||||
self.checked_claim_rate_denominator()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full denominator of the per-epoch payout rate, or
|
||||
/// [`RewardPoWConfigError::ClaimRateDenominatorOverflow`] if the product
|
||||
/// overflows `u64`.
|
||||
fn checked_claim_rate_denominator(&self) -> Result<NonZeroU64, RewardPoWConfigError> {
|
||||
let product = self
|
||||
.rate_den
|
||||
.get()
|
||||
.checked_mul(self.target_claim_per_block.get())
|
||||
.and_then(|partial| partial.checked_mul(self.expected_blocks_per_epoch.get()))
|
||||
.ok_or(RewardPoWConfigError::ClaimRateDenominatorOverflow {
|
||||
rate_den: self.rate_den,
|
||||
target_claim_per_block: self.target_claim_per_block,
|
||||
expected_blocks_per_epoch: self.expected_blocks_per_epoch,
|
||||
})?;
|
||||
Ok(NonZeroU64::new(product).expect("product of non-zero values is non-zero"))
|
||||
}
|
||||
|
||||
/// Full denominator of the per-epoch payout rate:
|
||||
/// `rate_den * target_claim_per_block * expected_blocks_per_epoch`.
|
||||
///
|
||||
/// The product is guaranteed not to overflow by [`Self::validate`], which
|
||||
/// runs on deserialization.
|
||||
#[must_use]
|
||||
pub fn claim_rate_denominator(&self) -> NonZeroU64 {
|
||||
self.checked_claim_rate_denominator()
|
||||
.expect("claim rate denominator overflow is rejected at config-load time")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
@@ -145,10 +298,97 @@ mod tests {
|
||||
use lb_utils::math::{NonNegativeRatio, PositiveF64};
|
||||
|
||||
use crate::{
|
||||
config::{BlendPoWConfig, PoWConfig},
|
||||
config::{BlendPoWConfig, PoWConfig, RewardPoWConfig, RewardPoWConfigError},
|
||||
mantle::sdp::{ServiceRewardsParameters, rewards::blend::RewardsParameters},
|
||||
};
|
||||
|
||||
/// A reward config with claiming disabled, standing in for a real
|
||||
/// deployment config in tests that don't exercise the reward parameters.
|
||||
fn disabled_reward_config() -> RewardPoWConfig {
|
||||
RewardPoWConfig {
|
||||
reward_pool_genesis: 1_000_000_000,
|
||||
epoch_reward_genesis: 1_000_000,
|
||||
initial_difficulty_seed: 1_000,
|
||||
ema_smoothing_factor: 9,
|
||||
ema_smoothing_precision: NonZeroU64::new(10).unwrap(),
|
||||
target_claims_per_block: 100,
|
||||
rate_num: 0,
|
||||
rate_den: NonZeroU64::MIN,
|
||||
target_claim_per_block: NonZeroU64::MIN,
|
||||
expected_blocks_per_epoch: NonZeroU64::MIN,
|
||||
slot_window: NonZeroU64::new(100).expect("100 is non-zero"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_reward_config_passes_validation() {
|
||||
assert_eq!(disabled_reward_config().validate(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_config_rejects_ema_factor_above_precision() {
|
||||
let mut config = disabled_reward_config();
|
||||
config.ema_smoothing_factor = config.ema_smoothing_precision.get() + 1;
|
||||
assert_eq!(
|
||||
config.validate(),
|
||||
Err(RewardPoWConfigError::EmaSmoothingFactorExceedsPrecision {
|
||||
factor: config.ema_smoothing_factor,
|
||||
precision: config.ema_smoothing_precision,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_config_accepts_ema_factor_equal_to_precision() {
|
||||
// F == P is q = 1 (full smoothing): a valid boundary, not a rejection.
|
||||
let mut config = disabled_reward_config();
|
||||
config.ema_smoothing_factor = config.ema_smoothing_precision.get();
|
||||
assert_eq!(config.validate(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_config_rejects_claim_rate_denominator_overflow() {
|
||||
// rate_den * target_claim_per_block * expected_blocks_per_epoch would
|
||||
// exceed u64::MAX.
|
||||
let mut config = disabled_reward_config();
|
||||
config.rate_den = NonZeroU64::MAX;
|
||||
config.target_claim_per_block = NonZeroU64::new(2).unwrap();
|
||||
config.expected_blocks_per_epoch = NonZeroU64::MIN;
|
||||
assert_eq!(
|
||||
config.validate(),
|
||||
Err(RewardPoWConfigError::ClaimRateDenominatorOverflow {
|
||||
rate_den: config.rate_den,
|
||||
target_claim_per_block: config.target_claim_per_block,
|
||||
expected_blocks_per_epoch: config.expected_blocks_per_epoch,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_invalid_reward_config() {
|
||||
// The invariant check runs on deserialization, so an invalid config is
|
||||
// rejected at config-load time instead of panicking later. The values
|
||||
// are serialized from a plain struct (bypassing validation) to emulate
|
||||
// a hand-written deployment config.
|
||||
let mut invalid = disabled_reward_config();
|
||||
invalid.ema_smoothing_factor = invalid.ema_smoothing_precision.get() + 1;
|
||||
let json = serde_json::to_string(&invalid).expect("serialize");
|
||||
let error = serde_json::from_str::<RewardPoWConfig>(&json)
|
||||
.expect_err("invalid reward config must be rejected on deserialization");
|
||||
assert!(
|
||||
error.to_string().contains("EMA smoothing factor"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_accepts_valid_reward_config() {
|
||||
let valid = disabled_reward_config();
|
||||
let json = serde_json::to_string(&valid).expect("serialize");
|
||||
let restored: RewardPoWConfig = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(restored, valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_snapshots() {
|
||||
let epoch_config = EpochConfig {
|
||||
@@ -202,6 +442,7 @@ mod tests {
|
||||
max_step: 1.try_into().unwrap(),
|
||||
target_transactions_per_block: 1.try_into().unwrap(),
|
||||
},
|
||||
reward: disabled_reward_config(),
|
||||
},
|
||||
};
|
||||
assert_eq!(config.epoch_length(), 100);
|
||||
@@ -264,6 +505,7 @@ mod tests {
|
||||
max_step: 1.try_into().unwrap(),
|
||||
target_transactions_per_block: 1.try_into().unwrap(),
|
||||
},
|
||||
reward: disabled_reward_config(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -335,6 +577,7 @@ mod tests {
|
||||
max_step: 1.try_into().unwrap(),
|
||||
target_transactions_per_block: 1.try_into().unwrap(),
|
||||
},
|
||||
reward: disabled_reward_config(),
|
||||
},
|
||||
};
|
||||
assert_eq!(config.epoch(1.into()), 0);
|
||||
|
||||
@@ -163,6 +163,7 @@ mod tests {
|
||||
damping_num: 1.try_into().unwrap(),
|
||||
damping_den_offset: 0,
|
||||
},
|
||||
reward: crate::cryptarchia::tests::disabled_reward_config(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -967,7 +967,11 @@ pub mod tests {
|
||||
let state = state
|
||||
.update_epoch_state::<HeaderId>(slot.into(), sdp, pow, config)
|
||||
.unwrap();
|
||||
*pow = pow.try_apply_header(&previous_epoch_state, state.epoch_state());
|
||||
*pow = pow.try_apply_header(
|
||||
&previous_epoch_state,
|
||||
state.epoch_state(),
|
||||
&config.pow_config.reward,
|
||||
);
|
||||
pow.record_block_txs(txs_in_block);
|
||||
state
|
||||
}
|
||||
@@ -1101,10 +1105,36 @@ pub mod tests {
|
||||
damping_num: NonZeroU32::new(1).unwrap(),
|
||||
damping_den_offset: 1,
|
||||
},
|
||||
reward: disabled_reward_config(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A reward config with claiming disabled, standing in for a real
|
||||
/// deployment config in tests that don't exercise the reward parameters.
|
||||
#[must_use]
|
||||
pub fn disabled_reward_config() -> crate::config::RewardPoWConfig {
|
||||
crate::config::RewardPoWConfig {
|
||||
reward_pool_genesis: 1_000_000_000,
|
||||
epoch_reward_genesis: 1_000_000,
|
||||
initial_difficulty_seed: 1_000,
|
||||
ema_smoothing_factor: 9,
|
||||
ema_smoothing_precision: NonZeroU64::new(10).unwrap(),
|
||||
target_claims_per_block: 100,
|
||||
rate_num: 0,
|
||||
rate_den: NonZeroU64::MIN,
|
||||
target_claim_per_block: NonZeroU64::MIN,
|
||||
expected_blocks_per_epoch: NonZeroU64::MIN,
|
||||
slot_window: NonZeroU64::new(100).expect("100 is non-zero"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Genesis `PoW` state built from [`disabled_reward_config`].
|
||||
#[must_use]
|
||||
pub fn pow_state() -> PowState {
|
||||
PowState::from_reward_config(&disabled_reward_config())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn genesis_state(utxos: &[Utxo]) -> LedgerState {
|
||||
let config = config();
|
||||
@@ -1474,7 +1504,7 @@ pub mod tests {
|
||||
|
||||
let sdp = SdpLedger::new(0.into());
|
||||
let mut state = genesis_state(&[utxo()]);
|
||||
let mut pow = PowState::new();
|
||||
let mut pow = pow_state();
|
||||
let blend_config = &config.pow_config.blend;
|
||||
let genesis_difficulty = state.epoch_state.blend_pow_difficulty;
|
||||
assert_eq!(
|
||||
@@ -1529,7 +1559,7 @@ pub mod tests {
|
||||
let config = config();
|
||||
assert_eq!(config.epoch_length(), 100);
|
||||
let sdp = SdpLedger::new(0.into());
|
||||
let mut pow = PowState::new();
|
||||
let mut pow = pow_state();
|
||||
|
||||
// Genesis (epoch 0). Stamp a distinct nonce on the current epoch so it
|
||||
// is recognisable after the boundary; genesis seeds the previous-epoch
|
||||
@@ -1561,7 +1591,7 @@ pub mod tests {
|
||||
|
||||
// A branch is both halves of the state together, since a block clones
|
||||
// and advances them as a pair.
|
||||
let mut ancestor = (genesis_state(&[utxo()]), PowState::new());
|
||||
let mut ancestor = (genesis_state(&[utxo()]), pow_state());
|
||||
|
||||
// A common ancestor carrying 4 transactions, in epoch 0.
|
||||
ancestor.0 = apply_block(ancestor.0, &mut ancestor.1, 10, 4, &sdp, &config);
|
||||
@@ -1622,7 +1652,7 @@ pub mod tests {
|
||||
let blend_config = &config.pow_config.blend;
|
||||
let sdp = SdpLedger::new(0.into());
|
||||
let mut state = genesis_state(&[utxo()]);
|
||||
let mut pow = PowState::new();
|
||||
let mut pow = pow_state();
|
||||
|
||||
// A single busy block in epoch 0, then no block at all in epoch 1.
|
||||
state = apply_block(state, &mut pow, 10, 1_000, &sdp, &config);
|
||||
@@ -1853,7 +1883,7 @@ pub mod tests {
|
||||
.update_epoch_state::<HeaderId>(
|
||||
slot,
|
||||
&SdpLedger::new(0.into()),
|
||||
&PowState::new(),
|
||||
&pow_state(),
|
||||
ledger_config,
|
||||
)
|
||||
.expect("Ledger needs to move forward");
|
||||
@@ -1863,7 +1893,7 @@ pub mod tests {
|
||||
.update_epoch_state::<HeaderId>(
|
||||
slot2,
|
||||
&SdpLedger::new(0.into()),
|
||||
&PowState::new(),
|
||||
&pow_state(),
|
||||
ledger_config,
|
||||
)
|
||||
.err();
|
||||
@@ -2160,7 +2190,7 @@ pub mod tests {
|
||||
.epoch_state_for_slot::<HeaderId>(
|
||||
epoch_0_slot,
|
||||
&SdpLedger::new(0.into()),
|
||||
&PowState::new(),
|
||||
&pow_state(),
|
||||
&config,
|
||||
)
|
||||
.expect("Should return epoch state for current epoch");
|
||||
@@ -2174,7 +2204,7 @@ pub mod tests {
|
||||
.epoch_state_for_slot::<HeaderId>(
|
||||
epoch_1_slot,
|
||||
&SdpLedger::new(0.into()),
|
||||
&PowState::new(),
|
||||
&pow_state(),
|
||||
&config,
|
||||
)
|
||||
.expect("Should return epoch state for next epoch");
|
||||
@@ -2191,7 +2221,7 @@ pub mod tests {
|
||||
.epoch_state_for_slot::<HeaderId>(
|
||||
epoch_2_slot,
|
||||
&SdpLedger::new(0.into()),
|
||||
&PowState::new(),
|
||||
&pow_state(),
|
||||
&config,
|
||||
)
|
||||
.expect("Should synthesize epoch state for skipped epoch");
|
||||
@@ -2224,7 +2254,7 @@ pub mod tests {
|
||||
&proof,
|
||||
&UncleSlots::default(),
|
||||
&SdpLedger::new(0.into()),
|
||||
&PowState::new(),
|
||||
&pow_state(),
|
||||
&config,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -2236,12 +2266,7 @@ pub mod tests {
|
||||
// First, synthesize epoch state for epoch 2
|
||||
let synthesized_ledger_state = ledger_state_1
|
||||
.clone()
|
||||
.update_epoch_state::<HeaderId>(
|
||||
slot,
|
||||
&SdpLedger::new(0.into()),
|
||||
&PowState::new(),
|
||||
&config,
|
||||
)
|
||||
.update_epoch_state::<HeaderId>(slot, &SdpLedger::new(0.into()), &pow_state(), &config)
|
||||
.unwrap();
|
||||
assert_eq!(synthesized_ledger_state.slot, slot);
|
||||
|
||||
@@ -2258,7 +2283,7 @@ pub mod tests {
|
||||
&proof,
|
||||
&UncleSlots::default(),
|
||||
&SdpLedger::new(0.into()),
|
||||
&PowState::new(),
|
||||
&pow_state(),
|
||||
&config,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -2506,12 +2531,7 @@ pub mod tests {
|
||||
let slot: Slot = (config.epoch_length() + 1).into();
|
||||
assert_eq!(config.epoch(slot), 1);
|
||||
let rotated = ledger
|
||||
.update_epoch_state::<HeaderId>(
|
||||
slot,
|
||||
&SdpLedger::new(0.into()),
|
||||
&PowState::new(),
|
||||
&config,
|
||||
)
|
||||
.update_epoch_state::<HeaderId>(slot, &SdpLedger::new(0.into()), &pow_state(), &config)
|
||||
.unwrap();
|
||||
|
||||
// The accumulated 600 must reach the price update: with a starting price
|
||||
|
||||
@@ -209,6 +209,7 @@ mod tests {
|
||||
damping_num: 1.try_into().unwrap(),
|
||||
damping_den_offset: 0,
|
||||
},
|
||||
reward: crate::cryptarchia::tests::disabled_reward_config(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ use crate::{
|
||||
stake::StakeInference,
|
||||
tests::{config, generate_proof},
|
||||
},
|
||||
mantle::{pow::PowState, sdp::SdpLedger},
|
||||
mantle::sdp::SdpLedger,
|
||||
};
|
||||
|
||||
type HeaderId = [u8; 32];
|
||||
@@ -372,7 +372,7 @@ fn apply_block_to_ledger(
|
||||
.update_epoch_state::<HeaderId>(
|
||||
slot,
|
||||
&SdpLedger::new(0.into()),
|
||||
&PowState::new(),
|
||||
&crate::cryptarchia::tests::pow_state(),
|
||||
ledger.config(),
|
||||
)
|
||||
.expect("epoch state update");
|
||||
|
||||
+46
-24
@@ -145,7 +145,9 @@ where
|
||||
// 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());
|
||||
state
|
||||
.mantle_ledger
|
||||
.add_seen_block(id.into(), state.slot(), &config);
|
||||
Self {
|
||||
states: HashTrieMapSync::new_sync().insert(id, state),
|
||||
config,
|
||||
@@ -248,7 +250,9 @@ impl LedgerState {
|
||||
// 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);
|
||||
state
|
||||
.mantle_ledger
|
||||
.add_seen_block(block_id.into(), slot, config);
|
||||
// Count the block's transactions into the epoch totals the Blend `PoW`
|
||||
// difficulty is retargeted from, for the same reason as above: only a
|
||||
// block that is actually applied, contents included, belongs in the
|
||||
@@ -267,6 +271,7 @@ impl LedgerState {
|
||||
matches!(payload, TxEventPayload::PoWRewardClaimed { .. })
|
||||
})
|
||||
.count() as u64,
|
||||
config,
|
||||
);
|
||||
let events = header_events
|
||||
.into_iter()
|
||||
@@ -821,8 +826,10 @@ impl LedgerState {
|
||||
Ok((self, balance, tx_events, deferred_zkps))
|
||||
}
|
||||
|
||||
fn update_pow_reward_difficulty(&mut self, claims_in_block: u64) {
|
||||
self.mantle_ledger.pow.update_difficulty(claims_in_block);
|
||||
fn update_pow_reward_difficulty(&mut self, claims_in_block: u64, config: &Config) {
|
||||
self.mantle_ledger
|
||||
.pow
|
||||
.update_difficulty(claims_in_block, &config.pow_config.reward);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2089,22 +2096,44 @@ mod tests {
|
||||
}
|
||||
|
||||
mod pow {
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
use lb_core::mantle::ops::{
|
||||
NoOpProof,
|
||||
pow::{ClaimPowRewardError, ClaimPowRewardOp, PowTarget},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::mantle::pow::ClaimPoWConstants;
|
||||
use crate::config::RewardPoWConfig;
|
||||
|
||||
/// A reward config with claiming disabled (`rate_num = 0`), standing in
|
||||
/// for a real deployment config.
|
||||
fn disabled_reward_config() -> RewardPoWConfig {
|
||||
RewardPoWConfig {
|
||||
reward_pool_genesis: 1_000_000_000,
|
||||
epoch_reward_genesis: 1_000_000,
|
||||
initial_difficulty_seed: 1_000,
|
||||
ema_smoothing_factor: 9,
|
||||
ema_smoothing_precision: NonZeroU64::new(10).expect("10 is non-zero"),
|
||||
target_claims_per_block: 100,
|
||||
rate_num: 0,
|
||||
rate_den: NonZeroU64::MIN,
|
||||
target_claim_per_block: NonZeroU64::MIN,
|
||||
expected_blocks_per_epoch: NonZeroU64::MIN,
|
||||
slot_window: NonZeroU64::new(100).expect("100 is non-zero"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
fn test_pool_config() -> RewardPoWConfig {
|
||||
RewardPoWConfig {
|
||||
rate_num: 1,
|
||||
rate_den: NonZeroU64::MIN,
|
||||
target_claim_per_block: NonZeroU64::new(10).expect("10 is non-zero"),
|
||||
expected_blocks_per_epoch: NonZeroU64::new(10).expect("10 is non-zero"),
|
||||
..disabled_reward_config()
|
||||
}
|
||||
}
|
||||
|
||||
/// A ledger state with a funded `PoW` pool (1000, `sigma_e` = 10) and
|
||||
@@ -2116,7 +2145,7 @@ mod tests {
|
||||
state
|
||||
.mantle_ledger
|
||||
.pow
|
||||
.add_rewards_to_pool::<TestPoolConstants>();
|
||||
.add_rewards_to_pool(&test_pool_config());
|
||||
state
|
||||
.mantle_ledger
|
||||
.pow
|
||||
@@ -2181,9 +2210,9 @@ mod tests {
|
||||
// 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);
|
||||
let (mut state, config) = pow_ledger_state(1_000);
|
||||
|
||||
state.update_pow_reward_difficulty(200);
|
||||
state.update_pow_reward_difficulty(200, &config);
|
||||
|
||||
assert_eq!(
|
||||
state.mantle_ledger.pow.reward_difficulty(),
|
||||
@@ -2220,20 +2249,13 @@ mod tests {
|
||||
// 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);
|
||||
// The default reward config disables claiming (`rate_num = 0`).
|
||||
state
|
||||
.mantle_ledger
|
||||
.pow
|
||||
.add_rewards_to_pool::<DisabledConstants>();
|
||||
.add_rewards_to_pool(&disabled_reward_config());
|
||||
assert_eq!(state.mantle_ledger.pow.epoch_reward(), 0);
|
||||
|
||||
let err = state
|
||||
@@ -2468,7 +2490,7 @@ mod tests {
|
||||
state
|
||||
.mantle_ledger
|
||||
.pow
|
||||
.add_rewards_to_pool::<TestPoolConstants>();
|
||||
.add_rewards_to_pool(&test_pool_config());
|
||||
assert_eq!(state.mantle_ledger.pow.reward_pool(), pool_before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
use lb_core::{
|
||||
crypto::{Hash, ZkHash},
|
||||
mantle::{
|
||||
@@ -157,4 +159,8 @@ impl OperationVerificationHelper for MantleOperationVerificationHelper<'_> {
|
||||
fn get_blocks_slot(&self) -> HashTrieMapSync<Hash, Slot> {
|
||||
self.ledger_state.pow.block_slots().clone()
|
||||
}
|
||||
|
||||
fn get_pow_slot_window(&self) -> NonZeroU64 {
|
||||
self.config.pow_config.reward.slot_window
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +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(),
|
||||
pow: pow::PowState::from_reward_config(&config.pow_config.reward),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ impl LedgerState {
|
||||
channels,
|
||||
sdp,
|
||||
leaders: leader::LeaderState::new(),
|
||||
pow: pow::PowState::new(),
|
||||
pow: pow::PowState::from_reward_config(&config.pow_config.reward),
|
||||
},
|
||||
tx_events,
|
||||
))
|
||||
@@ -155,7 +155,9 @@ 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);
|
||||
self.pow =
|
||||
self.pow
|
||||
.try_apply_header(last_epoch_state, epoch_state, &config.pow_config.reward);
|
||||
Ok((self, effect))
|
||||
}
|
||||
|
||||
@@ -167,10 +169,12 @@ impl LedgerState {
|
||||
/// 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) {
|
||||
pub fn add_seen_block(&mut self, block_hash: Hash, slot: Slot, config: &Config) {
|
||||
self.pow.add_seen_block_slots(block_hash, slot);
|
||||
self.pow.prune_seen_block_slots(slot);
|
||||
self.pow.prune_nullifiers_by_slots(slot);
|
||||
self.pow
|
||||
.prune_seen_block_slots(slot, config.pow_config.reward.slot_window);
|
||||
self.pow
|
||||
.prune_nullifiers_by_slots(slot, config.pow_config.reward.slot_window);
|
||||
}
|
||||
|
||||
pub fn try_apply_channel_inscription(
|
||||
|
||||
@@ -2,30 +2,20 @@ 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;
|
||||
}
|
||||
use crate::config::RewardPoWConfig;
|
||||
|
||||
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>(
|
||||
pub fn compute_new_reward_difficulty(
|
||||
claims_accepted_in_block: u64,
|
||||
current_block_reward_target: PowTarget,
|
||||
config: &RewardPoWConfig,
|
||||
) -> PowTarget {
|
||||
let smoothing_precision = config.ema_smoothing_precision.get();
|
||||
let smoothing_factor = config.ema_smoothing_factor;
|
||||
let target_claims_per_block = config.target_claims_per_block;
|
||||
|
||||
// (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)
|
||||
let observation_weight = smoothing_precision
|
||||
.checked_sub(smoothing_factor)
|
||||
.expect("EMA_SMOOTHING_FACTOR must not exceed EMA_SMOOTHING_PRECISION");
|
||||
|
||||
// The arithmetic happens on plain integers: `PowTarget` is a field
|
||||
@@ -42,17 +32,15 @@ pub fn compute_new_reward_difficulty<Constants: PoWDifficultyConstants>(
|
||||
// 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)
|
||||
+ BigUint::from(smoothing_factor) * 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;
|
||||
let demand_estimate_denominator = current_block_reward_target * 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
|
||||
let new_target = BigUint::from(target_claims_per_block) * demand_estimate_denominator
|
||||
/ demand_estimate_numerator;
|
||||
|
||||
// Cap at p - 1 (the maximum field element) so converting back into the
|
||||
@@ -63,16 +51,40 @@ pub fn compute_new_reward_difficulty<Constants: PoWDifficultyConstants>(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
use lb_groth16::AdditiveGroup as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A [`RewardPoWConfig`] with the difficulty controller set to
|
||||
/// `F/P = factor/precision` and `T = target_claims_per_block`. The other
|
||||
/// fields are unused by [`compute_new_reward_difficulty`] and take
|
||||
/// arbitrary (claiming-disabled) values.
|
||||
fn difficulty_config(
|
||||
factor: u64,
|
||||
precision: u64,
|
||||
target_claims_per_block: u64,
|
||||
) -> RewardPoWConfig {
|
||||
RewardPoWConfig {
|
||||
reward_pool_genesis: 1_000_000_000,
|
||||
epoch_reward_genesis: 1_000_000,
|
||||
initial_difficulty_seed: 1_000,
|
||||
ema_smoothing_factor: factor,
|
||||
ema_smoothing_precision: NonZeroU64::new(precision)
|
||||
.expect("test precision is non-zero"),
|
||||
target_claims_per_block,
|
||||
rate_num: 0,
|
||||
rate_den: NonZeroU64::MIN,
|
||||
target_claim_per_block: NonZeroU64::MIN,
|
||||
expected_blocks_per_epoch: NonZeroU64::MIN,
|
||||
slot_window: NonZeroU64::new(100).expect("100 is non-zero"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `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;
|
||||
fn test_config() -> RewardPoWConfig {
|
||||
difficulty_config(9, 10, 10)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -80,7 +92,7 @@ mod tests {
|
||||
// claims == T is the controller's fixed point.
|
||||
let target = PowTarget::from(1_000u64);
|
||||
assert_eq!(
|
||||
compute_new_reward_difficulty::<TestConstants>(10, target),
|
||||
compute_new_reward_difficulty(10, target, &test_config()),
|
||||
target
|
||||
);
|
||||
}
|
||||
@@ -90,7 +102,7 @@ mod tests {
|
||||
// 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)),
|
||||
compute_new_reward_difficulty(20, PowTarget::from(1_000u64), &test_config()),
|
||||
PowTarget::from(909u64)
|
||||
);
|
||||
}
|
||||
@@ -99,7 +111,7 @@ mod tests {
|
||||
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)),
|
||||
compute_new_reward_difficulty(5, PowTarget::from(1_000u64), &test_config()),
|
||||
PowTarget::from(1_052u64)
|
||||
);
|
||||
}
|
||||
@@ -109,7 +121,7 @@ mod tests {
|
||||
// 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)),
|
||||
compute_new_reward_difficulty(0, PowTarget::from(1_000u64), &test_config()),
|
||||
PowTarget::from(1_111u64)
|
||||
);
|
||||
}
|
||||
@@ -121,7 +133,7 @@ mod tests {
|
||||
// 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),
|
||||
compute_new_reward_difficulty(0, max_target, &test_config()),
|
||||
max_target
|
||||
);
|
||||
}
|
||||
@@ -133,7 +145,7 @@ mod tests {
|
||||
// on-target block leaves it unchanged.
|
||||
let target = PowTarget::from(BigUint::from(1u8) << 250);
|
||||
assert_eq!(
|
||||
compute_new_reward_difficulty::<TestConstants>(10, target),
|
||||
compute_new_reward_difficulty(10, target, &test_config()),
|
||||
target
|
||||
);
|
||||
}
|
||||
@@ -144,7 +156,7 @@ mod tests {
|
||||
// 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)),
|
||||
compute_new_reward_difficulty(u64::MAX, PowTarget::from(1_000u64), &test_config()),
|
||||
PowTarget::ZERO
|
||||
);
|
||||
}
|
||||
@@ -155,7 +167,7 @@ mod tests {
|
||||
// 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),
|
||||
compute_new_reward_difficulty(0, PowTarget::ZERO, &test_config()),
|
||||
PowTarget::ZERO
|
||||
);
|
||||
}
|
||||
@@ -165,14 +177,12 @@ mod tests {
|
||||
// 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)),
|
||||
compute_new_reward_difficulty(
|
||||
0,
|
||||
PowTarget::from(1_000u64),
|
||||
&difficulty_config(0, 10, 10)
|
||||
),
|
||||
PowTarget::from(100_000u64)
|
||||
);
|
||||
}
|
||||
@@ -181,19 +191,14 @@ mod tests {
|
||||
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 full_smoothing = difficulty_config(10, 10, 10);
|
||||
let target = PowTarget::from(1_000u64);
|
||||
assert_eq!(
|
||||
compute_new_reward_difficulty::<FullSmoothing>(0, target),
|
||||
compute_new_reward_difficulty(0, target, &full_smoothing),
|
||||
target
|
||||
);
|
||||
assert_eq!(
|
||||
compute_new_reward_difficulty::<FullSmoothing>(1_000_000, target),
|
||||
compute_new_reward_difficulty(1_000_000, target, &full_smoothing),
|
||||
target
|
||||
);
|
||||
}
|
||||
@@ -203,12 +208,10 @@ mod tests {
|
||||
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));
|
||||
let _ = compute_new_reward_difficulty(
|
||||
10,
|
||||
PowTarget::from(1_000u64),
|
||||
&difficulty_config(11, 10, 10),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+145
-160
@@ -8,9 +8,7 @@ use lb_core::{
|
||||
crypto::Hash,
|
||||
mantle::{
|
||||
Value,
|
||||
ops::pow::{
|
||||
ClaimPoWRewardExecutionContext, PowNullifier, PowReward, PowTarget, SLOT_WINDOW,
|
||||
},
|
||||
ops::pow::{ClaimPoWRewardExecutionContext, PowNullifier, PowReward, PowTarget},
|
||||
},
|
||||
};
|
||||
use lb_cryptarchia_engine::Slot;
|
||||
@@ -19,15 +17,13 @@ use rpds::HashTrieMapSync;
|
||||
|
||||
use crate::{
|
||||
EpochState,
|
||||
config::RewardPoWConfig,
|
||||
mantle::pow::{
|
||||
difficulty::{PoWDifficultySettings, compute_new_reward_difficulty},
|
||||
difficulty::compute_new_reward_difficulty,
|
||||
tx_density::{ClosedEpochLoad, TxDensity},
|
||||
},
|
||||
};
|
||||
|
||||
const POW_REWARD_POOL_GENESIS: PowReward = 1_000_000_000;
|
||||
const POW_EPOCH_REWARD_POOL_GENESIS: PowReward = 1_000_000;
|
||||
|
||||
/// `PoW` state of the mantle ledger.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PowState {
|
||||
@@ -59,8 +55,10 @@ pub struct RewardPowState {
|
||||
/// 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
|
||||
/// window-of-acceptance check and pruned as they age out of the
|
||||
/// configured acceptance window
|
||||
/// ([`slot_window`](crate::config::RewardPoWConfig::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>,
|
||||
@@ -78,26 +76,20 @@ pub struct BlendPowState {
|
||||
tx_density: TxDensity,
|
||||
}
|
||||
|
||||
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.
|
||||
/// Create the genesis `PoW` state from `config`: 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
|
||||
pub fn from_reward_config(config: &RewardPoWConfig) -> Self {
|
||||
Self {
|
||||
reward: RewardPowState {
|
||||
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),
|
||||
reward_pool: config.reward_pool_genesis,
|
||||
epoch_reward: config.epoch_reward_genesis,
|
||||
reward_difficulty: compute_new_reward_difficulty(
|
||||
config.initial_difficulty_seed,
|
||||
PowTarget::from(config.epoch_reward_genesis),
|
||||
config,
|
||||
),
|
||||
refill_rewards: 0,
|
||||
nullifiers: HashTrieMapSync::new_sync(),
|
||||
@@ -142,13 +134,13 @@ impl PowState {
|
||||
|
||||
/// 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) {
|
||||
pub(crate) fn add_rewards_to_pool(&mut self, config: &RewardPoWConfig) {
|
||||
self.reward.reward_pool = self
|
||||
.reward
|
||||
.reward_pool
|
||||
.saturating_add(self.reward.refill_rewards);
|
||||
self.reward.refill_rewards = 0;
|
||||
self.reward.epoch_reward = compute_epoch_pow_reward::<Constants>(self.reward.reward_pool);
|
||||
self.reward.epoch_reward = compute_epoch_pow_reward(self.reward.reward_pool, config);
|
||||
}
|
||||
|
||||
/// Add `reward` to the current epoch's pending `refill_rewards`.
|
||||
@@ -156,11 +148,9 @@ impl PowState {
|
||||
self.reward.refill_rewards = self.reward.refill_rewards.saturating_add(reward);
|
||||
}
|
||||
|
||||
pub(crate) fn update_difficulty(&mut self, claims_in_block: u64) {
|
||||
self.reward.reward_difficulty = compute_new_reward_difficulty::<PoWDifficultySettings>(
|
||||
claims_in_block,
|
||||
self.reward.reward_difficulty,
|
||||
);
|
||||
pub(crate) fn update_difficulty(&mut self, claims_in_block: u64, config: &RewardPoWConfig) {
|
||||
self.reward.reward_difficulty =
|
||||
compute_new_reward_difficulty(claims_in_block, self.reward.reward_difficulty, config);
|
||||
}
|
||||
|
||||
/// Slots of the recently seen blocks a claim may anchor to, by hash.
|
||||
@@ -177,8 +167,8 @@ impl PowState {
|
||||
/// 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));
|
||||
pub(crate) fn prune_seen_block_slots(&mut self, current: Slot, slot_window: NonZeroU64) {
|
||||
let cutoff = current.saturating_sub(Slot::from(slot_window.get()));
|
||||
self.reward.block_slots = self
|
||||
.reward
|
||||
.block_slots
|
||||
@@ -190,8 +180,8 @@ impl PowState {
|
||||
/// 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));
|
||||
pub(crate) fn prune_nullifiers_by_slots(&mut self, current: Slot, slot_window: NonZeroU64) {
|
||||
let cutoff = current.saturating_sub(Slot::from(slot_window.get()));
|
||||
self.reward.nullifiers = self
|
||||
.reward
|
||||
.nullifiers
|
||||
@@ -219,12 +209,13 @@ impl PowState {
|
||||
&self,
|
||||
previous_epoch: &EpochState,
|
||||
next_epoch: &EpochState,
|
||||
config: &RewardPoWConfig,
|
||||
) -> 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.add_rewards_to_pool(config);
|
||||
// Once per epoch crossed, so epochs skipped entirely close as empty
|
||||
// and are read as no load — matching how the other per-epoch
|
||||
// rotations treat them.
|
||||
@@ -244,52 +235,18 @@ impl PowState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// `PoW` reward pool balance, per the deployment's payout rate
|
||||
/// (`config.rate_num / config.claim_rate_denominator()`).
|
||||
///
|
||||
/// 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.
|
||||
/// `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());
|
||||
pub fn compute_epoch_pow_reward(pow_reward_pool: PowReward, config: &RewardPoWConfig) -> PowReward {
|
||||
let denominator = u64::from(config.claim_rate_denominator());
|
||||
let reward =
|
||||
u128::from(pow_reward_pool) * u128::from(Constants::RATE_NUM) / u128::from(denominator);
|
||||
u128::from(pow_reward_pool) * u128::from(config.rate_num) / u128::from(denominator);
|
||||
PowReward::try_from(reward).unwrap_or(PowReward::MAX)
|
||||
}
|
||||
|
||||
@@ -306,6 +263,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::UtxoTree;
|
||||
|
||||
const SLOT_WINDOW: NonZeroU64 = NonZeroU64::new(100).expect("100 is not 0");
|
||||
|
||||
fn epoch_state(epoch: u32) -> EpochState {
|
||||
EpochState {
|
||||
epoch: epoch.into(),
|
||||
@@ -319,13 +278,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// Genesis endowment values used by [`reward_config`], pinned here so the
|
||||
/// assertions read against a fixed number.
|
||||
const POW_REWARD_POOL_GENESIS: PowReward = 1_000_000_000;
|
||||
const POW_EPOCH_REWARD_POOL_GENESIS: PowReward = 1_000_000;
|
||||
|
||||
/// A reward config with claiming disabled (`rate_num = 0`), standing in for
|
||||
/// a real deployment config in tests that don't exercise the payout rate.
|
||||
fn reward_config() -> RewardPoWConfig {
|
||||
RewardPoWConfig {
|
||||
reward_pool_genesis: POW_REWARD_POOL_GENESIS,
|
||||
epoch_reward_genesis: POW_EPOCH_REWARD_POOL_GENESIS,
|
||||
initial_difficulty_seed: 1_000,
|
||||
ema_smoothing_factor: 9,
|
||||
ema_smoothing_precision: NonZeroU64::new(10).expect("10 is non-zero"),
|
||||
target_claims_per_block: 100,
|
||||
rate_num: 0,
|
||||
rate_den: NonZeroU64::MIN,
|
||||
target_claim_per_block: NonZeroU64::MIN,
|
||||
expected_blocks_per_epoch: NonZeroU64::MIN,
|
||||
slot_window: NonZeroU64::new(SLOT_WINDOW.get()).expect("SLOT_WINDOW is non-zero"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Genesis `PoW` state built from [`reward_config`].
|
||||
fn pow_state() -> PowState {
|
||||
PowState::from_reward_config(&reward_config())
|
||||
}
|
||||
|
||||
/// A payout rate of `1/100`: `sigma_e = pool / 100` (rate `1`, denominator
|
||||
/// `1 * 10 * 10`).
|
||||
fn test_pool_config() -> RewardPoWConfig {
|
||||
RewardPoWConfig {
|
||||
rate_num: 1,
|
||||
rate_den: NonZeroU64::MIN,
|
||||
target_claim_per_block: NonZeroU64::new(10).expect("10 is non-zero"),
|
||||
expected_blocks_per_epoch: NonZeroU64::new(10).expect("10 is non-zero"),
|
||||
..reward_config()
|
||||
}
|
||||
}
|
||||
|
||||
const BLOCK_A: Hash = [1u8; 32];
|
||||
@@ -333,7 +323,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn new_state_starts_with_genesis_values() {
|
||||
let state = PowState::new();
|
||||
let state = pow_state();
|
||||
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
|
||||
@@ -345,46 +335,42 @@ mod tests {
|
||||
|
||||
#[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);
|
||||
let config = test_pool_config();
|
||||
assert_eq!(compute_epoch_pow_reward(1_000, &config), 10);
|
||||
assert_eq!(compute_epoch_pow_reward(0, &config), 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);
|
||||
assert_eq!(compute_epoch_pow_reward(150, &config), 1);
|
||||
assert_eq!(compute_epoch_pow_reward(99, &config), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_epoch_pow_reward_disabled_is_always_zero() {
|
||||
assert_eq!(
|
||||
compute_epoch_pow_reward::<ClaimPoWDisabledConstants>(u64::MAX),
|
||||
0
|
||||
);
|
||||
// The default config disables claiming (`rate_num = 0`).
|
||||
assert_eq!(compute_epoch_pow_reward(u64::MAX, &reward_config()), 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;
|
||||
}
|
||||
// A rate with rate_num > 1: `sigma_e = pool * 2 / 4`.
|
||||
let high_rate = RewardPoWConfig {
|
||||
rate_num: 2,
|
||||
rate_den: NonZeroU64::MIN,
|
||||
target_claim_per_block: NonZeroU64::MIN,
|
||||
expected_blocks_per_epoch: NonZeroU64::new(4).expect("4 is non-zero"),
|
||||
..reward_config()
|
||||
};
|
||||
|
||||
// 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
|
||||
);
|
||||
// `pool * rate_num` must be widened past u64 or it overflows for any
|
||||
// rate_num > 1.
|
||||
assert_eq!(compute_epoch_pow_reward(u64::MAX, &high_rate), u64::MAX / 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rewards_to_pool_moves_refill_and_computes_reward() {
|
||||
let mut state = PowState::new();
|
||||
let mut state = pow_state();
|
||||
state.add_reward_refill_rewards(1_000);
|
||||
state.add_rewards_to_pool::<TestConstants>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
|
||||
assert_eq!(state.reward_pool(), POW_REWARD_POOL_GENESIS + 1_000);
|
||||
assert_eq!(
|
||||
@@ -395,22 +381,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn add_rewards_to_pool_accumulates_across_multiple_refills() {
|
||||
let mut state = PowState::new();
|
||||
let mut state = pow_state();
|
||||
state.add_reward_refill_rewards(400);
|
||||
state.add_reward_refill_rewards(600);
|
||||
state.add_rewards_to_pool::<TestConstants>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
|
||||
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();
|
||||
let mut state = pow_state();
|
||||
state.add_reward_refill_rewards(1_000);
|
||||
state.add_rewards_to_pool::<TestConstants>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
// Refill was reset by the call above: applying again must not add
|
||||
// anything further to the pool.
|
||||
state.add_rewards_to_pool::<TestConstants>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
|
||||
assert_eq!(state.reward_pool(), POW_REWARD_POOL_GENESIS + 1_000);
|
||||
assert_eq!(
|
||||
@@ -421,16 +407,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn add_rewards_to_pool_recomputes_reward_from_new_pool_each_time() {
|
||||
let mut state = PowState::new();
|
||||
let mut state = pow_state();
|
||||
state.add_reward_refill_rewards(1_000);
|
||||
state.add_rewards_to_pool::<TestConstants>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
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>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
|
||||
assert_eq!(state.reward_pool(), POW_REWARD_POOL_GENESIS + 10_000);
|
||||
assert_eq!(
|
||||
@@ -441,32 +427,32 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn refill_rewards_saturate_instead_of_overflowing() {
|
||||
let mut state = PowState::new();
|
||||
let mut state = pow_state();
|
||||
state.add_reward_refill_rewards(u64::MAX);
|
||||
state.add_reward_refill_rewards(1);
|
||||
state.add_rewards_to_pool::<TestConstants>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
|
||||
assert_eq!(state.reward_pool(), u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_pool_saturates_instead_of_overflowing() {
|
||||
let mut state = PowState::new();
|
||||
let mut state = pow_state();
|
||||
state.add_reward_refill_rewards(u64::MAX);
|
||||
state.add_rewards_to_pool::<TestConstants>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
state.add_reward_refill_rewards(u64::MAX);
|
||||
state.add_rewards_to_pool::<TestConstants>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
|
||||
assert_eq!(state.reward_pool(), u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_apply_header_is_noop_when_epoch_does_not_advance() {
|
||||
let mut state = PowState::new();
|
||||
let mut state = pow_state();
|
||||
state.add_reward_refill_rewards(500);
|
||||
let same_epoch = epoch_state(3);
|
||||
|
||||
let unchanged = state.try_apply_header(&same_epoch, &same_epoch);
|
||||
let unchanged = state.try_apply_header(&same_epoch, &same_epoch, &reward_config());
|
||||
|
||||
assert_eq!(unchanged, state);
|
||||
assert_eq!(unchanged.reward_pool(), POW_REWARD_POOL_GENESIS);
|
||||
@@ -474,54 +460,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn try_apply_header_is_noop_when_epoch_goes_backwards() {
|
||||
let mut state = PowState::new();
|
||||
let mut state = pow_state();
|
||||
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);
|
||||
let unchanged = state.try_apply_header(&later, &earlier, &reward_config());
|
||||
|
||||
assert_eq!(unchanged, state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_apply_header_does_not_mutate_the_receiver() {
|
||||
let mut state = PowState::new();
|
||||
let mut state = pow_state();
|
||||
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));
|
||||
drop(state.try_apply_header(&previous, &next, &reward_config()));
|
||||
|
||||
assert_eq!(state, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_apply_header_moves_pending_refill_into_pool_on_advance() {
|
||||
let mut state = PowState::new();
|
||||
let mut state = pow_state();
|
||||
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);
|
||||
let new_state = state.try_apply_header(&previous, &next, &reward_config());
|
||||
|
||||
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
|
||||
// With the default reward config (claiming disabled, `rate_num = 0`),
|
||||
// `epoch_reward` is zeroed at the first transition even though the
|
||||
// pool is well funded.
|
||||
let mut state = PowState::new();
|
||||
let mut state = pow_state();
|
||||
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);
|
||||
let new_state = state.try_apply_header(&previous, &next, &reward_config());
|
||||
|
||||
assert_eq!(new_state.reward_pool(), POW_REWARD_POOL_GENESIS + 1_000_000);
|
||||
assert_eq!(new_state.epoch_reward(), 0);
|
||||
@@ -529,12 +514,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn try_apply_header_across_multiple_epoch_jump_applies_once() {
|
||||
let mut state = PowState::new();
|
||||
let mut state = pow_state();
|
||||
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);
|
||||
let new_state = state.try_apply_header(&previous, &next, &reward_config());
|
||||
|
||||
assert_eq!(new_state.reward_pool(), POW_REWARD_POOL_GENESIS + 500);
|
||||
}
|
||||
@@ -543,35 +528,35 @@ mod tests {
|
||||
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();
|
||||
let mut state = pow_state();
|
||||
state.add_reward_refill_rewards(200);
|
||||
let same = epoch_state(2);
|
||||
let mut state = state.try_apply_header(&same, &same);
|
||||
let mut state = state.try_apply_header(&same, &same, &reward_config());
|
||||
|
||||
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);
|
||||
let new_state = state.try_apply_header(&previous, &next, &reward_config());
|
||||
|
||||
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();
|
||||
let mut state = pow_state();
|
||||
|
||||
// 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));
|
||||
state.add_seen_block_slots(BLOCK_B, Slot::from(5 + SLOT_WINDOW.get()));
|
||||
state.prune_seen_block_slots(Slot::from(5 + SLOT_WINDOW.get()), 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));
|
||||
state.prune_seen_block_slots(Slot::from(5 + SLOT_WINDOW.get() + 1), SLOT_WINDOW);
|
||||
assert!(!state.block_slots().contains_key(&BLOCK_A));
|
||||
assert!(state.block_slots().contains_key(&BLOCK_B));
|
||||
}
|
||||
@@ -585,8 +570,8 @@ mod tests {
|
||||
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();
|
||||
.insert(recent_nullifier, Slot::from(5 + SLOT_WINDOW.get()));
|
||||
let mut state = pow_state();
|
||||
state.update_from_claim_execution_result(&ClaimPoWRewardExecutionContext {
|
||||
reward_pool: state.reward_pool(),
|
||||
epoch_reward: 0,
|
||||
@@ -598,22 +583,22 @@ mod tests {
|
||||
|
||||
// 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));
|
||||
state.prune_nullifiers_by_slots(Slot::from(5 + SLOT_WINDOW.get()), 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));
|
||||
state.prune_nullifiers_by_slots(Slot::from(5 + SLOT_WINDOW.get() + 1), SLOT_WINDOW);
|
||||
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();
|
||||
let mut state = pow_state();
|
||||
state.add_reward_refill_rewards(1_000);
|
||||
state.add_rewards_to_pool::<TestConstants>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
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);
|
||||
@@ -659,9 +644,9 @@ mod tests {
|
||||
// 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();
|
||||
let mut state = pow_state();
|
||||
state.add_reward_refill_rewards(1_000);
|
||||
state.add_rewards_to_pool::<TestConstants>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
assert_eq!(
|
||||
state.epoch_reward(),
|
||||
(POW_REWARD_POOL_GENESIS + 1_000) / 100
|
||||
@@ -669,13 +654,13 @@ mod tests {
|
||||
|
||||
// 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>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
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>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
assert_eq!(state.epoch_reward(), 0);
|
||||
}
|
||||
|
||||
@@ -685,9 +670,9 @@ mod tests {
|
||||
// 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();
|
||||
let mut state = pow_state();
|
||||
state.add_reward_refill_rewards(1_000);
|
||||
state.add_rewards_to_pool::<TestConstants>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
|
||||
// Mid-epoch: block rewards accrue, then a claim drains the pool.
|
||||
state.add_reward_refill_rewards(500);
|
||||
@@ -695,7 +680,7 @@ mod tests {
|
||||
|
||||
// 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>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
assert_eq!(state.reward_pool(), 1_490);
|
||||
assert_eq!(state.epoch_reward(), 14);
|
||||
}
|
||||
@@ -707,10 +692,10 @@ mod tests {
|
||||
// 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();
|
||||
let mut state = pow_state();
|
||||
state.update_from_claim_execution_result(&claim_result(0, nullifier));
|
||||
|
||||
let new_state = state.try_apply_header(&epoch_state(0), &epoch_state(1));
|
||||
let new_state = state.try_apply_header(&epoch_state(0), &epoch_state(1), &reward_config());
|
||||
|
||||
assert!(new_state.nullifiers().contains_key(&nullifier));
|
||||
}
|
||||
@@ -721,9 +706,9 @@ mod tests {
|
||||
// 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();
|
||||
let mut state = pow_state();
|
||||
state.add_reward_refill_rewards(1_000);
|
||||
state.add_rewards_to_pool::<TestConstants>();
|
||||
state.add_rewards_to_pool(&test_pool_config());
|
||||
state.update_from_claim_execution_result(&claim_result(990, PowNullifier::from(Fr::ONE)));
|
||||
state.add_reward_refill_rewards(123);
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ pub struct ServiceParameters {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PoWConfig {
|
||||
pub blend: BlendPoWConfig,
|
||||
pub reward: RewardPoWConfig,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -105,3 +106,8 @@ pub struct BlendPoWConfig {
|
||||
pub damping_num: NonZeroU32,
|
||||
pub damping_den_offset: u32,
|
||||
}
|
||||
|
||||
// The reward parameters are used verbatim, so the ledger type is reused
|
||||
// directly: deserializing the deployment config runs its invariant checks
|
||||
// (`RewardPoWConfig::validate`), rejecting an invalid config at load time.
|
||||
pub use lb_ledger::config::RewardPoWConfig;
|
||||
|
||||
@@ -80,6 +80,9 @@ impl ServiceConfig {
|
||||
.blend
|
||||
.target_transactions_per_block,
|
||||
},
|
||||
// Reused verbatim: the deployment mirror already holds the
|
||||
// validated ledger type.
|
||||
reward: self.deployment.pow_config.reward.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,21 @@ cryptarchia:
|
||||
max_step: 2
|
||||
damping_num: 1
|
||||
damping_den_offset: 1
|
||||
reward:
|
||||
# Token-reward PoW parameters. Payout is disabled by default
|
||||
# (rate_num = 0), matching the pre-configurable behaviour.
|
||||
reward_pool_genesis: 1000000000
|
||||
epoch_reward_genesis: 1000000
|
||||
initial_difficulty_seed: 1000
|
||||
ema_smoothing_factor: 9
|
||||
ema_smoothing_precision: 10
|
||||
target_claims_per_block: 100
|
||||
rate_num: 0
|
||||
rate_den: 1
|
||||
target_claim_per_block: 1
|
||||
expected_blocks_per_epoch: 1
|
||||
# Acceptance window in slots; must match the mining service.
|
||||
slot_window: 100
|
||||
sdp_config:
|
||||
service_params:
|
||||
BN:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use core::num::NonZeroU64;
|
||||
|
||||
use lb_pow_service::PoWServiceSettings;
|
||||
use lb_services_utils::overwatch::RecoveryData;
|
||||
|
||||
@@ -10,14 +12,19 @@ pub struct ServiceConfig {
|
||||
}
|
||||
|
||||
impl ServiceConfig {
|
||||
/// `slot_window` is the consensus acceptance window, sourced from the
|
||||
/// cryptarchia deployment configuration so the mining service and the
|
||||
/// ledger agree on a single value.
|
||||
#[must_use]
|
||||
pub const fn into_pow_service_settings(
|
||||
self,
|
||||
recovery_data: RecoveryData,
|
||||
slot_window: NonZeroU64,
|
||||
) -> PoWServiceSettings {
|
||||
PoWServiceSettings {
|
||||
claim_address: self.user.claim_address,
|
||||
mining: self.user.mining,
|
||||
slot_window,
|
||||
recovery_data,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,11 @@ pub fn run_node_from_config(
|
||||
) -> Result<Overwatch<RuntimeServiceId>, DynError> {
|
||||
let blend_rewards_params = config.deployment.blend_reward_params();
|
||||
|
||||
// The PoW mining service must use the same acceptance window as consensus;
|
||||
// read it from the cryptarchia deployment config before that config is
|
||||
// moved into the cryptarchia service settings below.
|
||||
let pow_slot_window = config.deployment.cryptarchia.pow_config.reward.slot_window;
|
||||
|
||||
let storage_config = StorageConfig {
|
||||
user: config.user.storage,
|
||||
}
|
||||
@@ -199,7 +204,7 @@ pub fn run_node_from_config(
|
||||
let pow_config = PoWConfig {
|
||||
user: config.user.pow,
|
||||
}
|
||||
.into_pow_service_settings(recovery_data);
|
||||
.into_pow_service_settings(recovery_data, pow_slot_window);
|
||||
|
||||
let tracing_config = config::tracing::ServiceConfig {
|
||||
user: config.user.tracing,
|
||||
|
||||
@@ -40,6 +40,21 @@ cryptarchia:
|
||||
max_step: 2
|
||||
damping_num: 1
|
||||
damping_den_offset: 1
|
||||
reward:
|
||||
# Token-reward PoW parameters. Payout is disabled by default
|
||||
# (rate_num = 0), matching the pre-configurable behaviour.
|
||||
reward_pool_genesis: 1000000000
|
||||
epoch_reward_genesis: 1000000
|
||||
initial_difficulty_seed: 1000
|
||||
ema_smoothing_factor: 9
|
||||
ema_smoothing_precision: 10
|
||||
target_claims_per_block: 100
|
||||
rate_num: 0
|
||||
rate_den: 1
|
||||
target_claim_per_block: 1
|
||||
expected_blocks_per_epoch: 1
|
||||
# Acceptance window in slots; must match the mining service.
|
||||
slot_window: 100
|
||||
sdp_config:
|
||||
service_params:
|
||||
BN:
|
||||
|
||||
@@ -511,7 +511,7 @@ mod pol_tests {
|
||||
use lb_groth16::{Fr, fr_from_bytes_unchecked};
|
||||
use lb_key_management_system_service::keys::{UnsecuredZkKey, ZkKey};
|
||||
use lb_ledger::{
|
||||
config::{BlendPoWConfig, ModulusShift, PoWConfig},
|
||||
config::{BlendPoWConfig, ModulusShift, PoWConfig, RewardPoWConfig},
|
||||
mantle::sdp::{
|
||||
Config as SdpConfig, ServiceRewardsParameters, rewards::blend::RewardsParameters,
|
||||
},
|
||||
@@ -722,6 +722,24 @@ mod pol_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A reward config with claiming disabled, standing in for a real
|
||||
/// deployment config in tests.
|
||||
fn disabled_reward_config() -> RewardPoWConfig {
|
||||
RewardPoWConfig {
|
||||
reward_pool_genesis: 1_000_000_000,
|
||||
epoch_reward_genesis: 1_000_000,
|
||||
initial_difficulty_seed: 1_000,
|
||||
ema_smoothing_factor: 9,
|
||||
ema_smoothing_precision: core::num::NonZeroU64::new(10).unwrap(),
|
||||
target_claims_per_block: 100,
|
||||
rate_num: 0,
|
||||
rate_den: core::num::NonZeroU64::MIN,
|
||||
target_claim_per_block: core::num::NonZeroU64::MIN,
|
||||
expected_blocks_per_epoch: core::num::NonZeroU64::MIN,
|
||||
slot_window: core::num::NonZeroU64::new(100).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_config() -> lb_ledger::Config {
|
||||
lb_ledger::Config {
|
||||
epoch_config: EpochConfig {
|
||||
@@ -770,6 +788,7 @@ mod pol_tests {
|
||||
max_step: 1.try_into().unwrap(),
|
||||
target_transactions_per_block: 1.try_into().unwrap(),
|
||||
},
|
||||
reward: disabled_reward_config(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ mod tests {
|
||||
use lb_cryptarchia_engine::{EpochConfig, Slot, UncleSlots};
|
||||
use lb_ledger::{
|
||||
LedgerState,
|
||||
config::{BlendPoWConfig, ModulusShift, PoWConfig},
|
||||
config::{BlendPoWConfig, ModulusShift, PoWConfig, RewardPoWConfig},
|
||||
mantle::sdp::{ServiceRewardsParameters, rewards},
|
||||
};
|
||||
use lb_network_service::{NetworkService, backends::NetworkBackend, message::ChainSyncEvent};
|
||||
@@ -941,6 +941,24 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// A reward config with claiming disabled, standing in for a real
|
||||
/// deployment config in tests.
|
||||
fn disabled_reward_config() -> RewardPoWConfig {
|
||||
RewardPoWConfig {
|
||||
reward_pool_genesis: 1_000_000_000,
|
||||
epoch_reward_genesis: 1_000_000,
|
||||
initial_difficulty_seed: 1_000,
|
||||
ema_smoothing_factor: 9,
|
||||
ema_smoothing_precision: NonZeroU64::new(10).unwrap(),
|
||||
target_claims_per_block: 100,
|
||||
rate_num: 0,
|
||||
rate_den: NonZeroU64::MIN,
|
||||
target_claim_per_block: NonZeroU64::MIN,
|
||||
expected_blocks_per_epoch: NonZeroU64::MIN,
|
||||
slot_window: NonZeroU64::new(100).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn ledger_config() -> lb_ledger::Config {
|
||||
let epoch_config = EpochConfig {
|
||||
@@ -994,6 +1012,7 @@ mod tests {
|
||||
max_step: 1.try_into().unwrap(),
|
||||
target_transactions_per_block: 1.try_into().unwrap(),
|
||||
},
|
||||
reward: disabled_reward_config(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,13 +133,31 @@ mod tests {
|
||||
};
|
||||
use lb_cryptarchia_engine::{State::Bootstrapping, UncleSlots};
|
||||
use lb_ledger::{
|
||||
config::{BlendPoWConfig, ModulusShift, PoWConfig},
|
||||
config::{BlendPoWConfig, ModulusShift, PoWConfig, RewardPoWConfig},
|
||||
mantle::sdp::{ServiceRewardsParameters, rewards},
|
||||
};
|
||||
use lb_utils::math::{NonNegativeRatio, PositiveF64};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A reward config with claiming disabled, standing in for a real
|
||||
/// deployment config in tests.
|
||||
fn disabled_reward_config() -> RewardPoWConfig {
|
||||
RewardPoWConfig {
|
||||
reward_pool_genesis: 1_000_000_000,
|
||||
epoch_reward_genesis: 1_000_000,
|
||||
initial_difficulty_seed: 1_000,
|
||||
ema_smoothing_factor: 9,
|
||||
ema_smoothing_precision: NonZeroU64::new(10).unwrap(),
|
||||
target_claims_per_block: 100,
|
||||
rate_num: 0,
|
||||
rate_den: NonZeroU64::MIN,
|
||||
target_claim_per_block: NonZeroU64::MIN,
|
||||
expected_blocks_per_epoch: NonZeroU64::MIN,
|
||||
slot_window: NonZeroU64::new(100).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(clippy::too_many_lines, reason = "Test function")]
|
||||
fn save_prunable_forks() {
|
||||
@@ -198,6 +216,7 @@ mod tests {
|
||||
max_step: 1.try_into().unwrap(),
|
||||
target_transactions_per_block: 1.try_into().unwrap(),
|
||||
},
|
||||
reward: disabled_reward_config(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -381,6 +400,7 @@ mod tests {
|
||||
max_step: 1.try_into().unwrap(),
|
||||
target_transactions_per_block: 1.try_into().unwrap(),
|
||||
},
|
||||
reward: disabled_reward_config(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ use lb_groth16::{AdditiveGroup as _, Fr};
|
||||
use lb_key_management_system_keys::keys::{Ed25519Key, ZkKey};
|
||||
use lb_ledger::{
|
||||
LedgerState,
|
||||
config::{BlendPoWConfig, ModulusShift, PoWConfig},
|
||||
config::{BlendPoWConfig, ModulusShift, PoWConfig, RewardPoWConfig},
|
||||
mantle::sdp::{ServiceRewardsParameters, rewards},
|
||||
};
|
||||
use lb_storage_service::{
|
||||
@@ -434,6 +434,24 @@ fn test_chain_with_next_block() -> (Cryptarchia, Block<SignedMantleTx<Preverifie
|
||||
(cryptarchia, block)
|
||||
}
|
||||
|
||||
/// A reward config with claiming disabled, standing in for a real deployment
|
||||
/// config in tests.
|
||||
fn disabled_reward_config() -> RewardPoWConfig {
|
||||
RewardPoWConfig {
|
||||
reward_pool_genesis: 1_000_000_000,
|
||||
epoch_reward_genesis: 1_000_000,
|
||||
initial_difficulty_seed: 1_000,
|
||||
ema_smoothing_factor: 9,
|
||||
ema_smoothing_precision: core::num::NonZeroU64::new(10).unwrap(),
|
||||
target_claims_per_block: 100,
|
||||
rate_num: 0,
|
||||
rate_den: core::num::NonZeroU64::MIN,
|
||||
target_claim_per_block: core::num::NonZeroU64::MIN,
|
||||
expected_blocks_per_epoch: core::num::NonZeroU64::MIN,
|
||||
slot_window: core::num::NonZeroU64::new(100).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn ledger_config(security_param: NonZero<u32>) -> lb_ledger::Config {
|
||||
let mut service_params = HashMap::new();
|
||||
@@ -486,6 +504,7 @@ pub fn ledger_config(security_param: NonZero<u32>) -> lb_ledger::Config {
|
||||
max_step: 1.try_into().unwrap(),
|
||||
target_transactions_per_block: 1.try_into().unwrap(),
|
||||
},
|
||||
reward: disabled_reward_config(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+35
-21
@@ -2,7 +2,7 @@ use core::fmt::{Debug, Display};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
marker::PhantomData,
|
||||
num::NonZeroUsize,
|
||||
num::{NonZeroU64, NonZeroUsize},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ use lb_core::{
|
||||
ledger::{Inputs, InputsError, Outputs},
|
||||
ops::{
|
||||
NoOpProof, OpId as _,
|
||||
pow::{ClaimPowRewardOp, PowNullifier, SLOT_WINDOW},
|
||||
pow::{ClaimPowRewardOp, PowNullifier},
|
||||
transfer::TransferOp,
|
||||
},
|
||||
traits::Hashable as _,
|
||||
@@ -129,6 +129,10 @@ pub struct PoWServiceSettings {
|
||||
/// concurrency).
|
||||
#[serde(default)]
|
||||
pub mining: PoWMiningSettings,
|
||||
/// Acceptance window, in slots, a mined ticket stays claimable for. Must
|
||||
/// match the network's consensus `slot_window` (sourced from the same
|
||||
/// deployment configuration); a ticket outside it can never be claimed.
|
||||
pub slot_window: NonZeroU64,
|
||||
/// Storage-recovery bookkeeping, populated by the runtime on startup.
|
||||
#[serde(skip)]
|
||||
pub recovery_data: RecoveryData,
|
||||
@@ -276,6 +280,10 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_lines,
|
||||
reason = "The service run loop is cohesive and clearer kept in one place."
|
||||
)]
|
||||
async fn run(self) -> Result<(), DynError> {
|
||||
let Self {
|
||||
service_resources_handle,
|
||||
@@ -327,6 +335,7 @@ where
|
||||
cryptarchia_api.clone(),
|
||||
pool,
|
||||
settings.mining.max_tickets_per_block,
|
||||
settings.slot_window,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -370,6 +379,7 @@ where
|
||||
&blend_api,
|
||||
settings.claim_address,
|
||||
&mut state,
|
||||
settings.slot_window,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
@@ -382,7 +392,7 @@ where
|
||||
}
|
||||
}
|
||||
PoWServiceMessage::ClaimableRewardsInfo { response } => {
|
||||
respond_claimable_rewards(&cryptarchia_api, &mut state, &state_updater, response).await;
|
||||
respond_claimable_rewards(&cryptarchia_api, &mut state, &state_updater, response, settings.slot_window).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,7 +403,7 @@ where
|
||||
// previously stored tickets whose window has since closed.
|
||||
let current_slot = winning_ticket.block_slot;
|
||||
state.ready_to_claim.push(winning_ticket);
|
||||
prune_expired_tickets(&mut state, current_slot);
|
||||
prune_expired_tickets(&mut state, current_slot, settings.slot_window);
|
||||
info!(
|
||||
target: LOG_TARGET,
|
||||
"Mined a winning ticket 💲; total claimable tickets {}",
|
||||
@@ -421,6 +431,7 @@ async fn claim_ready_rewards<CryptarchiaService, BlendService, RuntimeServiceId>
|
||||
blend_api: &BlendServiceApi<BlendService, RuntimeServiceId>,
|
||||
claim_address: ZkPublicKey,
|
||||
state: &mut PoWServiceState,
|
||||
slot_window: NonZeroU64,
|
||||
) -> Result<Option<TxHash>, DynError>
|
||||
where
|
||||
CryptarchiaService: CryptarchiaServiceData<Tx: Send + Sync>,
|
||||
@@ -433,7 +444,7 @@ where
|
||||
|
||||
// Drop any tickets whose window has closed before building the batch, so an
|
||||
// expired ticket never poisons the tx.
|
||||
prune_expired_tickets(state, info.slot);
|
||||
prune_expired_tickets(state, info.slot, slot_window);
|
||||
if state.ready_to_claim.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -497,10 +508,10 @@ where
|
||||
|
||||
/// Whether a ticket anchored to a block at `block_slot` is still within its
|
||||
/// reward window at `current_slot`: claimable while
|
||||
/// `current_slot - block_slot <= SLOT_WINDOW`.
|
||||
fn is_within_reward_window(block_slot: Slot, current_slot: Slot) -> bool {
|
||||
/// `current_slot - block_slot <= slot_window`.
|
||||
fn is_within_reward_window(block_slot: Slot, current_slot: Slot, slot_window: NonZeroU64) -> bool {
|
||||
u64::from(block_slot)
|
||||
.checked_add(SLOT_WINDOW)
|
||||
.checked_add(slot_window.get())
|
||||
.is_some_and(|last_claimable_slot| last_claimable_slot >= u64::from(current_slot))
|
||||
}
|
||||
|
||||
@@ -509,14 +520,14 @@ fn is_within_reward_window(block_slot: Slot, current_slot: Slot) -> bool {
|
||||
/// Once a ticket falls out of the window it can never be claimed, so keeping it
|
||||
/// only bloats the state and would poison a claim tx built from the batch. Both
|
||||
/// the ready and pending sets are pruned in place.
|
||||
fn prune_expired_tickets(state: &mut PoWServiceState, current_slot: Slot) {
|
||||
fn prune_expired_tickets(state: &mut PoWServiceState, current_slot: Slot, slot_window: NonZeroU64) {
|
||||
let before = state.ready_to_claim.len() + state.pending_to_claim.len();
|
||||
state
|
||||
.ready_to_claim
|
||||
.retain(|ticket| is_within_reward_window(ticket.block_slot, current_slot));
|
||||
.retain(|ticket| is_within_reward_window(ticket.block_slot, current_slot, slot_window));
|
||||
state
|
||||
.pending_to_claim
|
||||
.retain(|ticket| is_within_reward_window(ticket.block_slot, current_slot));
|
||||
.retain(|ticket| is_within_reward_window(ticket.block_slot, current_slot, slot_window));
|
||||
let pruned = before - (state.ready_to_claim.len() + state.pending_to_claim.len());
|
||||
if pruned > 0 {
|
||||
info!(target: LOG_TARGET, "Pruned {pruned} expired PoW ticket(s)");
|
||||
@@ -531,6 +542,7 @@ async fn respond_claimable_rewards<CryptarchiaService, RuntimeServiceId>(
|
||||
state: &mut PoWServiceState,
|
||||
state_updater: &StateUpdater<Option<PoWServiceState>>,
|
||||
response: oneshot::Sender<ClaimableRewardsInfo>,
|
||||
slot_window: NonZeroU64,
|
||||
) where
|
||||
CryptarchiaService: CryptarchiaServiceData<Tx: Send + Sync>,
|
||||
RuntimeServiceId: Sync,
|
||||
@@ -544,9 +556,9 @@ async fn respond_claimable_rewards<CryptarchiaService, RuntimeServiceId>(
|
||||
};
|
||||
// Drop expired tickets first, so the report covers only what is still
|
||||
// claimable.
|
||||
prune_expired_tickets(state, current_slot);
|
||||
prune_expired_tickets(state, current_slot, slot_window);
|
||||
state_updater.update(Some(state.clone()));
|
||||
let claimable = claimable_rewards_info(&state.ready_to_claim, current_slot);
|
||||
let claimable = claimable_rewards_info(&state.ready_to_claim, current_slot, slot_window);
|
||||
if response.send(claimable).is_err() {
|
||||
error!(target: LOG_TARGET, "ClaimableRewardsInfo response receiver was dropped");
|
||||
}
|
||||
@@ -635,16 +647,17 @@ where
|
||||
///
|
||||
/// Callers prune expired tickets first (see [`prune_expired_tickets`]), so
|
||||
/// every ticket here is assumed to still be within the window: a ticket
|
||||
/// anchored to a block at `block_slot` has `block_slot + SLOT_WINDOW -
|
||||
/// anchored to a block at `block_slot` has `block_slot + slot_window -
|
||||
/// current_slot` slots of remaining lifetime.
|
||||
fn claimable_rewards_info(
|
||||
ready_to_claim: &[WinningTicket],
|
||||
current_slot: Slot,
|
||||
slot_window: NonZeroU64,
|
||||
) -> ClaimableRewardsInfo {
|
||||
let current = u64::from(current_slot);
|
||||
let slots_until_expiry: Vec<Slot> = ready_to_claim
|
||||
.iter()
|
||||
.map(|ticket| Slot::new(u64::from(ticket.block_slot) + SLOT_WINDOW - current))
|
||||
.map(|ticket| Slot::new(u64::from(ticket.block_slot) + slot_window.get() - current))
|
||||
.collect();
|
||||
ClaimableRewardsInfo {
|
||||
claimable_tickets: slots_until_expiry.len(),
|
||||
@@ -874,7 +887,7 @@ fn estimate_reward_claim_fee(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::{collections::HashMap, num::NonZeroU64};
|
||||
|
||||
use lb_chain_service::Slot;
|
||||
use lb_core::{
|
||||
@@ -900,6 +913,7 @@ mod tests {
|
||||
const REWARD: u64 = 1_000_000;
|
||||
const POOL: u64 = 1_000_000_000;
|
||||
|
||||
const SLOT_WINDOW: NonZeroU64 = NonZeroU64::new(100).expect("100 is not 0");
|
||||
/// A distinct dummy note id, derived like the ones the builder
|
||||
/// reconstructs.
|
||||
fn note_id(seed: u8) -> NoteId {
|
||||
@@ -1187,7 +1201,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn claimable_rewards_info_is_empty_without_tickets() {
|
||||
let info = claimable_rewards_info(&[], Slot::new(100));
|
||||
let info = claimable_rewards_info(&[], Slot::new(100), SLOT_WINDOW);
|
||||
assert_eq!(info.claimable_tickets, 0);
|
||||
assert!(info.slots_until_expiry.is_empty());
|
||||
}
|
||||
@@ -1196,7 +1210,7 @@ mod tests {
|
||||
fn claimable_rewards_info_reports_remaining_window_per_ticket() {
|
||||
// SLOT_WINDOW is 100, so a block at slot S is claimable up to slot S + 100.
|
||||
let tickets = [winning_ticket(50), winning_ticket(90)];
|
||||
let info = claimable_rewards_info(&tickets, Slot::new(100));
|
||||
let info = claimable_rewards_info(&tickets, Slot::new(100), SLOT_WINDOW);
|
||||
assert_eq!(info.claimable_tickets, 2);
|
||||
// (50 + 100) - 100 = 50, (90 + 100) - 100 = 90
|
||||
assert_eq!(info.slots_until_expiry, vec![Slot::new(50), Slot::new(90)]);
|
||||
@@ -1205,7 +1219,7 @@ mod tests {
|
||||
#[test]
|
||||
fn claimable_rewards_info_includes_the_last_valid_slot() {
|
||||
// A block at slot 50 is still claimable at exactly slot 150 (gap == window).
|
||||
let info = claimable_rewards_info(&[winning_ticket(50)], Slot::new(150));
|
||||
let info = claimable_rewards_info(&[winning_ticket(50)], Slot::new(150), SLOT_WINDOW);
|
||||
assert_eq!(info.claimable_tickets, 1);
|
||||
assert_eq!(info.slots_until_expiry, vec![Slot::new(0)]);
|
||||
}
|
||||
@@ -1218,7 +1232,7 @@ mod tests {
|
||||
ready_to_claim: vec![winning_ticket(10), winning_ticket(150)],
|
||||
pending_to_claim: vec![winning_ticket(20), winning_ticket(180)],
|
||||
};
|
||||
prune_expired_tickets(&mut state, Slot::new(200));
|
||||
prune_expired_tickets(&mut state, Slot::new(200), SLOT_WINDOW);
|
||||
|
||||
let ready_slots: Vec<u64> = state
|
||||
.ready_to_claim
|
||||
@@ -1241,7 +1255,7 @@ mod tests {
|
||||
ready_to_claim: vec![winning_ticket(50)],
|
||||
pending_to_claim: vec![],
|
||||
};
|
||||
prune_expired_tickets(&mut state, Slot::new(150));
|
||||
prune_expired_tickets(&mut state, Slot::new(150), SLOT_WINDOW);
|
||||
assert_eq!(state.ready_to_claim.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
iter,
|
||||
num::NonZeroUsize,
|
||||
num::{NonZeroU64, NonZeroUsize},
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
@@ -21,7 +21,7 @@ use lb_chain_service::{
|
||||
use lb_core::{
|
||||
crypto::ZkHash,
|
||||
header::HeaderId,
|
||||
mantle::ops::pow::{ClaimPowRewardOp, PowTarget, SLOT_WINDOW},
|
||||
mantle::ops::pow::{ClaimPowRewardOp, PowTarget},
|
||||
};
|
||||
use lb_key_management_system_keys::keys::UnsecuredZkKey;
|
||||
use lb_ledger::LedgerState;
|
||||
@@ -92,6 +92,9 @@ pub struct TicketGenerator {
|
||||
/// Maximum number of ticket-search attempts kept in flight concurrently for
|
||||
/// each block (the `buffer_unordered` degree of every per-block search).
|
||||
max_tickets_per_block: NonZeroUsize,
|
||||
/// Acceptance window, in slots: a block older than this leaves the reward
|
||||
/// window and its search is pruned. Matches the consensus `slot_window`.
|
||||
slot_window: NonZeroU64,
|
||||
}
|
||||
|
||||
impl TicketGenerator {
|
||||
@@ -109,6 +112,7 @@ impl TicketGenerator {
|
||||
cryptarchia_api: CryptarchiaServiceApi<CryptarchiaServiceData, RuntimeServiceId>,
|
||||
pool: Arc<ThreadPool>,
|
||||
max_tickets_per_block: NonZeroUsize,
|
||||
slot_window: NonZeroU64,
|
||||
) -> Result<Self, lb_chain_service::api::ApiError>
|
||||
where
|
||||
CryptarchiaServiceData:
|
||||
@@ -130,6 +134,7 @@ impl TicketGenerator {
|
||||
tip: HeaderId::from([0u8; 32]),
|
||||
pool,
|
||||
max_tickets_per_block,
|
||||
slot_window,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -300,7 +305,7 @@ impl Stream for TicketGenerator {
|
||||
))) => {
|
||||
this.tip = tip;
|
||||
// compute which slot is old enough
|
||||
let frontier_slot = tip_slot.saturating_sub(Slot::new(SLOT_WINDOW));
|
||||
let frontier_slot = tip_slot.saturating_sub(Slot::new(this.slot_window.get()));
|
||||
// trigger new stream if its new enough
|
||||
if frontier_slot < block_slot {
|
||||
let stream = new_block_search_stream(
|
||||
@@ -345,7 +350,7 @@ impl Stream for TicketGenerator {
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
num::NonZeroUsize,
|
||||
num::{NonZeroU64, NonZeroUsize},
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
@@ -365,6 +370,8 @@ mod tests {
|
||||
search_winner_ticket,
|
||||
};
|
||||
|
||||
const SLOT_WINDOW: NonZeroU64 = NonZeroU64::new(100).expect("100 is not 0");
|
||||
|
||||
/// A never-resolving search stream, used to populate the map under test.
|
||||
fn pending_stream() -> WinnerTicketStream {
|
||||
Box::pin(stream::pending())
|
||||
@@ -521,6 +528,7 @@ mod tests {
|
||||
tip: HeaderId::from([0u8; 32]),
|
||||
pool: test_pool(),
|
||||
max_tickets_per_block: NonZeroUsize::new(4).unwrap(),
|
||||
slot_window: SLOT_WINDOW,
|
||||
};
|
||||
assert!(matches!(poll_once(&mut generator), Poll::Ready(None)));
|
||||
}
|
||||
@@ -541,6 +549,7 @@ mod tests {
|
||||
tip: HeaderId::from([0u8; 32]),
|
||||
pool: test_pool(),
|
||||
max_tickets_per_block: NonZeroUsize::new(4).unwrap(),
|
||||
slot_window: SLOT_WINDOW,
|
||||
};
|
||||
assert!(matches!(poll_once(&mut generator), Poll::Ready(None)));
|
||||
}
|
||||
@@ -556,6 +565,7 @@ mod tests {
|
||||
tip: HeaderId::from([0u8; 32]),
|
||||
pool: test_pool(),
|
||||
max_tickets_per_block: NonZeroUsize::new(16).unwrap(),
|
||||
slot_window: SLOT_WINDOW,
|
||||
};
|
||||
assert!(matches!(poll_once(&mut generator), Poll::Pending));
|
||||
}
|
||||
@@ -578,6 +588,7 @@ mod tests {
|
||||
tip,
|
||||
pool: test_pool(),
|
||||
max_tickets_per_block: NonZeroUsize::new(16).unwrap(),
|
||||
slot_window: SLOT_WINDOW,
|
||||
};
|
||||
|
||||
let Poll::Ready(Some(winner)) = poll_once(&mut generator) else {
|
||||
@@ -602,6 +613,7 @@ mod tests {
|
||||
tip: HeaderId::from([0u8; 32]),
|
||||
pool: test_pool(),
|
||||
max_tickets_per_block: NonZeroUsize::new(4).unwrap(),
|
||||
slot_window: SLOT_WINDOW,
|
||||
};
|
||||
|
||||
// A winner already produced is emitted first...
|
||||
|
||||
@@ -66,10 +66,27 @@ const BLEND_POW_MAX_STEP: u64 = 2;
|
||||
const BLEND_POW_DAMPING_NUM: u32 = 1;
|
||||
const BLEND_POW_DAMPING_DEN_OFFSET: u32 = 1;
|
||||
|
||||
// Token-reward PoW parameters. Payout is disabled (`rate_num = 0`).
|
||||
const REWARD_POW_POOL_GENESIS: u64 = 1_000_000_000;
|
||||
const REWARD_POW_EPOCH_REWARD_GENESIS: u64 = 1_000_000;
|
||||
const REWARD_POW_INITIAL_DIFFICULTY_SEED: u64 = 1_000;
|
||||
const REWARD_POW_EMA_SMOOTHING_FACTOR: u64 = 9;
|
||||
const REWARD_POW_EMA_SMOOTHING_PRECISION: u64 = 10;
|
||||
const REWARD_POW_TARGET_CLAIMS_PER_BLOCK: u64 = 100;
|
||||
const REWARD_POW_RATE_NUM: u64 = 0;
|
||||
const REWARD_POW_RATE_DEN: u64 = 1;
|
||||
const REWARD_POW_TARGET_CLAIM_PER_BLOCK: u64 = 1;
|
||||
const REWARD_POW_EXPECTED_BLOCKS_PER_EPOCH: u64 = 1;
|
||||
const REWARD_POW_SLOT_WINDOW: u64 = 100;
|
||||
|
||||
const MEMPOOL_TOPIC: &str = "mantle_e2e_tests";
|
||||
const DEFAULT_PROTOCOL_NAMESPACE: &str = "integration/logos-blockchain";
|
||||
|
||||
#[must_use]
|
||||
#[expect(
|
||||
clippy::too_many_lines,
|
||||
reason = "Deployment settings assembled in a single place for clarity."
|
||||
)]
|
||||
pub fn e2e_deployment_settings_with_genesis_block(
|
||||
genesis_block: &GenesisBlock,
|
||||
) -> DeploymentSettings {
|
||||
@@ -160,6 +177,22 @@ pub fn e2e_deployment_settings_with_genesis_block(
|
||||
damping_num: NonZero::new(BLEND_POW_DAMPING_NUM).unwrap(),
|
||||
damping_den_offset: BLEND_POW_DAMPING_DEN_OFFSET,
|
||||
},
|
||||
reward: lb_node::config::cryptarchia::deployment::RewardPoWConfig {
|
||||
reward_pool_genesis: REWARD_POW_POOL_GENESIS,
|
||||
epoch_reward_genesis: REWARD_POW_EPOCH_REWARD_GENESIS,
|
||||
initial_difficulty_seed: REWARD_POW_INITIAL_DIFFICULTY_SEED,
|
||||
ema_smoothing_factor: REWARD_POW_EMA_SMOOTHING_FACTOR,
|
||||
ema_smoothing_precision: NonZero::new(REWARD_POW_EMA_SMOOTHING_PRECISION)
|
||||
.unwrap(),
|
||||
target_claims_per_block: REWARD_POW_TARGET_CLAIMS_PER_BLOCK,
|
||||
rate_num: REWARD_POW_RATE_NUM,
|
||||
rate_den: NonZero::new(REWARD_POW_RATE_DEN).unwrap(),
|
||||
target_claim_per_block: NonZero::new(REWARD_POW_TARGET_CLAIM_PER_BLOCK)
|
||||
.unwrap(),
|
||||
expected_blocks_per_epoch: NonZero::new(REWARD_POW_EXPECTED_BLOCKS_PER_EPOCH)
|
||||
.unwrap(),
|
||||
slot_window: NonZero::new(REWARD_POW_SLOT_WINDOW).unwrap(),
|
||||
},
|
||||
},
|
||||
},
|
||||
time: TimeDeploymentSettings {
|
||||
|
||||
+20
-1
@@ -894,7 +894,7 @@ mod tests {
|
||||
Ed25519Key, Ed25519Signature, UnsecuredZkKey, ZkSignature,
|
||||
};
|
||||
use lb_ledger::{
|
||||
config::{BlendPoWConfig, ModulusShift, PoWConfig},
|
||||
config::{BlendPoWConfig, ModulusShift, PoWConfig, RewardPoWConfig},
|
||||
mantle::sdp::{ServiceRewardsParameters, rewards},
|
||||
};
|
||||
use lb_pol::LotteryConstants;
|
||||
@@ -1850,6 +1850,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A reward config with claiming disabled, standing in for a real
|
||||
/// deployment config in tests.
|
||||
fn disabled_reward_config() -> RewardPoWConfig {
|
||||
RewardPoWConfig {
|
||||
reward_pool_genesis: 1_000_000_000,
|
||||
epoch_reward_genesis: 1_000_000,
|
||||
initial_difficulty_seed: 1_000,
|
||||
ema_smoothing_factor: 9,
|
||||
ema_smoothing_precision: NonZeroU64::new(10).unwrap(),
|
||||
target_claims_per_block: 100,
|
||||
rate_num: 0,
|
||||
rate_den: NonZeroU64::MIN,
|
||||
target_claim_per_block: NonZeroU64::MIN,
|
||||
expected_blocks_per_epoch: NonZeroU64::MIN,
|
||||
slot_window: NonZeroU64::new(100).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn ledger_config() -> lb_ledger::Config {
|
||||
let epoch_config = EpochConfig {
|
||||
@@ -1903,6 +1921,7 @@ mod tests {
|
||||
max_step: 1.try_into().unwrap(),
|
||||
target_transactions_per_block: 1.try_into().unwrap(),
|
||||
},
|
||||
reward: disabled_reward_config(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user