feat(pow): Thread in pow constants as deployment settings (#3400)

This commit is contained in:
Daniel Sanchez
2026-08-25 13:07:29 +00:00
committed by GitHub
parent fe9e4af21b
commit cc8587827a
30 changed files with 887 additions and 323 deletions
+22 -13
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -163,6 +163,7 @@ mod tests {
damping_num: 1.try_into().unwrap(),
damping_den_offset: 0,
},
reward: crate::cryptarchia::tests::disabled_reward_config(),
},
}
}
+44 -24
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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);
}
}
+6
View File
@@ -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
}
}
+10 -6
View File
@@ -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(
+62 -59
View File
@@ -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
View File
@@ -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:
+7
View File
@@ -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,
}
}
+6 -1
View File
@@ -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:
+20 -1
View File
@@ -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(),
},
}
}
+21 -1
View File
@@ -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(),
},
};
+20 -1
View File
@@ -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
View File
@@ -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);
}
}
+16 -4
View File
@@ -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...
+33
View File
@@ -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
View File
@@ -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(),
},
}
}