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