Push half baked solution waiting for full RFC to be specced out

This commit is contained in:
Antonio Antonino
2026-08-12 12:43:29 +02:00
parent 57bd1bdd84
commit 220dfac337
7 changed files with 243 additions and 91 deletions
@@ -29,9 +29,6 @@ cryptarchia:
learning_rate: 1.0
pow_config:
blend:
# TODO: calibrate. `base_difficulty` is the threshold at the reference
# load of `target_transactions_per_block`; damping alpha = 1/2 and a
# per-epoch clamp of 4x follow the spec's own examples.
base_difficulty: '40420f0000000000000000000000000000000000000000000000000000000000'
target_transactions_per_block: 10
max_step: 4
@@ -29,9 +29,6 @@ cryptarchia:
learning_rate: 0.5
pow_config:
blend:
# TODO: calibrate. `base_difficulty` is the threshold at the reference
# load of `target_transactions_per_block`; damping alpha = 1/2 and a
# per-epoch clamp of 4x follow the spec's own examples.
base_difficulty: '40420f0000000000000000000000000000000000000000000000000000000000'
target_transactions_per_block: 10
max_step: 4
@@ -29,9 +29,6 @@ cryptarchia:
learning_rate: 1.0
pow_config:
blend:
# TODO: calibrate. `base_difficulty` is the threshold at the reference
# load of `target_transactions_per_block`; damping alpha = 1/2 and a
# per-epoch clamp of 4x follow the spec's own examples.
base_difficulty: '40420f0000000000000000000000000000000000000000000000000000000000'
target_transactions_per_block: 10
max_step: 4
+100 -8
View File
@@ -1,6 +1,6 @@
mod block_density;
mod stake;
mod tx_density;
pub(crate) mod tx_density;
use std::sync::{Arc, LazyLock};
@@ -105,13 +105,10 @@ impl EpochState {
fn update_from_ledger(self, ledger: &LedgerState, sdp: &SdpLedger, config: &Config) -> Self {
let nonce_snapshot_slot = config.nonce_snapshot(self.epoch);
let (nonce, blend_pow_difficulty) = if ledger.slot < nonce_snapshot_slot {
let (txs_in_previous_epoch, blocks_in_previous_epoch) =
ledger.tx_density.last_closed_epoch_totals();
(
ledger.nonce,
compute_epoch_blend_difficulty(
txs_in_previous_epoch,
blocks_in_previous_epoch,
ledger.tx_density.last_closed_epoch_load(),
ledger.epoch_state.blend_pow_difficulty,
&config.pow_config.blend,
),
@@ -831,6 +828,7 @@ pub mod tests {
use super::*;
use crate::{
Ledger,
cryptarchia::tx_density::ClosedEpochLoad,
leader_proof::LeaderProof,
mantle::sdp::{
ServiceRewardsParameters,
@@ -1422,7 +1420,11 @@ pub mod tests {
let epoch_1_difficulty = state.epoch_state.blend_pow_difficulty;
assert_eq!(
epoch_1_difficulty,
compute_epoch_blend_difficulty(0, 0, genesis_difficulty, blend_config)
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(0, 0),
genesis_difficulty,
blend_config
)
);
// Epoch 1, with blocks on both sides of epoch 2's snapshot slot.
@@ -1445,7 +1447,11 @@ pub mod tests {
assert_eq!(state.epoch_state.epoch, 2);
assert_eq!(
state.epoch_state.blend_pow_difficulty,
compute_epoch_blend_difficulty(12, 3, epoch_1_difficulty, blend_config)
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(12, 3),
epoch_1_difficulty,
blend_config
)
);
assert_eq!(
state.epoch_state.blend_pow_difficulty,
@@ -1453,6 +1459,88 @@ pub mod tests {
);
}
#[test]
fn blend_difficulty_counts_only_the_branch_it_is_read_from() {
// The block and transaction counters live in the per-block
// `LedgerState`, which `Ledger::prepare_update` derives from a clone of
// the parent's. Competing branches therefore accumulate independently,
// and reading the difficulty at a tip counts exactly the blocks on that
// tip's ancestry — no unwinding is needed when a re-org picks a
// different tip.
let config = config();
let blend_config = &config.pow_config.blend;
let sdp = SdpLedger::new(0.into());
// A common ancestor carrying 4 transactions, in epoch 0.
let mut ancestor = genesis_state(&[utxo()]);
ancestor = ancestor
.update_epoch_state::<HeaderId>(10.into(), &sdp, &config)
.unwrap();
ancestor.record_block_txs(4);
// Two competing blocks for the same slot, each built on its own clone
// of the ancestor's state.
let mut busy_branch = ancestor.clone();
busy_branch = busy_branch
.update_epoch_state::<HeaderId>(20.into(), &sdp, &config)
.unwrap();
busy_branch.record_block_txs(96);
let mut quiet_branch = ancestor;
quiet_branch = quiet_branch
.update_epoch_state::<HeaderId>(20.into(), &sdp, &config)
.unwrap();
quiet_branch.record_block_txs(6);
// Carry both branches past the epoch-0 close and the epoch-2 snapshot.
let advance = |mut state: LedgerState| {
for slot in [100u64, 110, 200] {
state = state
.update_epoch_state::<HeaderId>(slot.into(), &sdp, &config)
.unwrap();
state.record_block_txs(0);
}
state
};
let busy_branch = advance(busy_branch);
let quiet_branch = advance(quiet_branch);
// Both branches enter epoch 1 with the same difficulty: no epoch had
// closed when epoch 1's value was snapshotted, so both eased by the
// full clamp step from the baseline.
let epoch_1_difficulty = compute_epoch_blend_difficulty(
ClosedEpochLoad::new(0, 0),
blend_config.base_difficulty,
blend_config,
);
// Each tip's epoch 2 then reads its own epoch 0: 100 transactions over
// two blocks on one branch, 10 over two on the other. The shared
// ancestor's 4 transactions are counted once on each — never twice, and
// never leaked from the branch that lost.
assert_eq!(
busy_branch.epoch_state.blend_pow_difficulty,
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(100, 2),
epoch_1_difficulty,
blend_config
)
);
assert_eq!(
quiet_branch.epoch_state.blend_pow_difficulty,
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(10, 2),
epoch_1_difficulty,
blend_config
)
);
// The busier branch admits fewer `PoW`-backed messages.
assert!(
busy_branch.epoch_state.blend_pow_difficulty
< quiet_branch.epoch_state.blend_pow_difficulty
);
}
#[test]
fn blend_difficulty_reads_a_skipped_epoch_as_no_load() {
let config = config();
@@ -1490,7 +1578,11 @@ pub mod tests {
assert_eq!(state.epoch_state.epoch, 3);
assert_eq!(
state.epoch_state.blend_pow_difficulty,
compute_epoch_blend_difficulty(0, 0, epoch_2_difficulty, blend_config)
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(0, 0),
epoch_2_difficulty,
blend_config
)
);
}
+72 -25
View File
@@ -7,23 +7,11 @@
//! boundary and consumed later, at the nonce snapshot slot of the epoch after
//! that.
/// Totals accumulated over a single epoch.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct EpochTotals {
blocks: u64,
txs: u64,
}
impl EpochTotals {
const fn record_block(&mut self, txs_in_block: u64) {
self.blocks = self.blocks.saturating_add(1);
self.txs = self.txs.saturating_add(txs_in_block);
}
}
use serde::{Deserialize, Serialize};
/// The rolling block and transaction counts of the epoch being extended and of
/// the last epoch that closed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TxDensity {
/// Totals of the epoch the ledger is currently extending, still growing
/// with every applied block.
@@ -42,17 +30,73 @@ impl TxDensity {
/// Close the current epoch's totals and start counting a new epoch.
///
/// Called once per epoch crossed, so that epochs skipped entirely (no
/// block was produced in them) close as empty and are read as zero
/// demand.
/// block was produced in them) close as empty and are read as no load.
pub(super) const fn close_epoch(&mut self) {
self.last_closed_epoch = self.current_epoch;
self.current_epoch = EpochTotals { blocks: 0, txs: 0 };
}
/// The totals — `(transactions, blocks)` — of the last closed epoch, the
/// observation the Blend difficulty retarget is computed from.
pub(super) const fn last_closed_epoch_totals(&self) -> (u64, u64) {
(self.last_closed_epoch.txs, self.last_closed_epoch.blocks)
/// The load of the last closed epoch the observation the Blend
/// difficulty retarget reads.
pub(crate) const fn last_closed_epoch_load(&self) -> ClosedEpochLoad {
ClosedEpochLoad {
transactions: self.last_closed_epoch.txs,
blocks: self.last_closed_epoch.blocks,
}
}
}
/// The transaction load of a single epoch that has *closed*.
///
/// The retarget must never read an epoch that is still being extended — its
/// totals would still be growing, and a difficulty derived from them would
/// depend on when it was read. Only [`TxDensity::close_epoch`] can put an
/// epoch's counts into this type, so passing an open epoch to the controller
/// does not compile rather than silently mis-targeting the difficulty. Naming
/// the two counts also keeps them from being transposed at the call site,
/// where as bare integers they are indistinguishable.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ClosedEpochLoad {
transactions: u64,
blocks: u64,
}
impl ClosedEpochLoad {
/// Transactions carried by the whole epoch.
pub const fn transactions(self) -> u64 {
self.transactions
}
/// Blocks the epoch produced. Zero for an epoch that was skipped entirely.
pub const fn blocks(self) -> u64 {
self.blocks
}
}
#[cfg(test)]
impl ClosedEpochLoad {
/// Test-only: stand in for an epoch closed by the ledger, so the
/// controller can be exercised over loads a test does not have to
/// accumulate block by block.
pub const fn new(transactions: u64, blocks: u64) -> Self {
Self {
transactions,
blocks,
}
}
}
/// Totals accumulated over a single epoch.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
struct EpochTotals {
blocks: u64,
txs: u64,
}
impl EpochTotals {
const fn record_block(&mut self, txs_in_block: u64) {
self.blocks = self.blocks.saturating_add(1);
self.txs = self.txs.saturating_add(txs_in_block);
}
}
@@ -63,16 +107,19 @@ mod tests {
#[test]
fn totals_accumulate_until_the_epoch_closes() {
let mut density = TxDensity::default();
assert_eq!(density.last_closed_epoch_totals(), (0, 0));
assert_eq!(density.last_closed_epoch_load(), ClosedEpochLoad::new(0, 0));
density.record_block(3);
density.record_block(0);
density.record_block(7);
// Still open: nothing is observable yet.
assert_eq!(density.last_closed_epoch_totals(), (0, 0));
assert_eq!(density.last_closed_epoch_load(), ClosedEpochLoad::new(0, 0));
density.close_epoch();
assert_eq!(density.last_closed_epoch_totals(), (10, 3));
assert_eq!(
density.last_closed_epoch_load(),
ClosedEpochLoad::new(10, 3)
);
}
#[test]
@@ -81,7 +128,7 @@ mod tests {
density.record_block(5);
density.close_epoch();
density.record_block(100);
assert_eq!(density.last_closed_epoch_totals(), (5, 1));
assert_eq!(density.last_closed_epoch_load(), ClosedEpochLoad::new(5, 1));
}
#[test]
@@ -92,6 +139,6 @@ mod tests {
density.record_block(5);
density.close_epoch();
density.close_epoch();
assert_eq!(density.last_closed_epoch_totals(), (0, 0));
assert_eq!(density.last_closed_epoch_load(), ClosedEpochLoad::new(0, 0));
}
}
+4 -2
View File
@@ -250,8 +250,10 @@ impl LedgerState {
// block that is actually applied, contents included, belongs in the
// epoch's average.
let mut txs_in_block = 0u64;
let (mut state, tx_events) = state
.try_apply_contents::<_, _, Profile>(config, txs.inspect(|_| txs_in_block += 1))?;
let (mut state, tx_events) = state.try_apply_contents::<_, _, Profile>(
config,
txs.inspect(|_| txs_in_block = txs_in_block.saturating_add(1)),
)?;
state.cryptarchia_ledger.record_block_txs(txs_in_block);
state.update_pow_reward_difficulty(
// count all claimed rewards
+67 -47
View File
@@ -7,59 +7,42 @@
//! means real traffic already supplies an anonymity set, so `PoW` entry can be
//! rate-limited harder; thin load eases the threshold so `PoW`-backed messages
//! come in and build the anonymity set.
//!
//! The controller is parallel to the reward-difficulty retarget in
//! [`super::difficulty`], but is driven by a transaction count rather than a
//! claim rate, and moves once per epoch rather than once per block.
use lb_core::mantle::ops::pow::PowTarget;
use lb_groth16::{Field as _, fr_to_bytes};
use num_bigint::BigUint;
use crate::config::BlendPoWConfig;
use crate::{config::BlendPoWConfig, cryptarchia::tx_density::ClosedEpochLoad};
/// Retarget `d_blend` from a whole epoch's transaction load.
///
/// With `load = avg_txs_per_block / T_tx` and a damping exponent
/// `alpha = a / b <= 1`, the new threshold is the baseline divided down by the
/// load — smaller target, harder puzzle, as load rises:
///
/// ```text
/// d_blend = BASE / load^alpha, clamped to [previous / k, previous * k]
/// ```
///
/// The response is deliberately gentle: sub-linear in the load (at
/// `alpha = 1/2`, quadrupling the transaction count only halves the
/// threshold), averaged over a whole epoch, and bounded per epoch by the
/// `max_step` factor `k`. An adversary stuffing blocks to shrink the anonymity
/// set therefore has to pay for it every epoch, for a small and capped effect.
///
/// An epoch that carried no transactions at all — including an epoch with no
/// blocks — is read as no observed load and eases the threshold as far as this
/// epoch's clamp allows.
pub fn compute_epoch_blend_difficulty(
txs_in_epoch: u64,
blocks_in_epoch: u64,
load: ClosedEpochLoad,
previous_difficulty: PowTarget,
config: &BlendPoWConfig,
) -> PowTarget {
// The arithmetic happens on plain integers: `PowTarget` is a field
// element, whose division (multiplication by the modular inverse) does not
// compute a ratio.
let previous = BigUint::from_bytes_le(&fr_to_bytes(&previous_difficulty));
let previous_difficulty = BigUint::from(previous_difficulty);
let numerator = BigUint::from(load.transactions());
let max_step = BigUint::from(config.max_step.get());
let clamp_upper_bound = previous_difficulty * max_step;
if numerator == BigUint::ZERO {
return clamp_upper_bound; // no load observed: as easy as this epoch's clamp allows
}
let clamp_lower_bound = previous_difficulty / max_step;
let lo = previous_difficulty / config.max_step.get();
let max_step = BigUint::from(config.max_step.get());
// Bound the change to at most a factor of `k` in either direction.
let low = &previous / &max_step;
let high = previous * max_step;
// `load` is kept exactly as the ratio `numerator / denominator`
// (they are equal at the reference load); no division is performed here.
let numerator = BigUint::from(txs_in_epoch);
if numerator == BigUint::ZERO {
// No load observed: as easy as this epoch's clamp allows.
return into_target(high);
}
let denominator = BigUint::from(config.target_transactions_per_block.get()) * blocks_in_epoch;
let denominator = BigUint::from(config.target_transactions_per_block.get()) * load.blocks();
// target = BASE / load^alpha
// = (BASE^b * denominator^a // numerator^a)^(1/b)
@@ -105,7 +88,11 @@ mod tests {
// so the threshold is the baseline itself.
let config = config();
assert_eq!(
compute_epoch_blend_difficulty(70, 7, config.base_difficulty, &config),
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(70, 7),
config.base_difficulty,
&config
),
config.base_difficulty
);
}
@@ -115,7 +102,11 @@ mod tests {
// At alpha = 1/2, quadrupling the load only halves the threshold.
let config = config();
assert_eq!(
compute_epoch_blend_difficulty(280, 7, config.base_difficulty, &config),
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(280, 7),
config.base_difficulty,
&config
),
PowTarget::from(500_000u64)
);
}
@@ -127,7 +118,11 @@ mod tests {
// the clamp does not bind.
let config = config();
assert_eq!(
compute_epoch_blend_difficulty(20, 8, config.base_difficulty, &config),
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(20, 8),
config.base_difficulty,
&config
),
PowTarget::from(2_000_000u64)
);
}
@@ -137,7 +132,11 @@ mod tests {
// Nothing observed: the threshold moves to the top of the clamp.
let config = config();
assert_eq!(
compute_epoch_blend_difficulty(0, 7, PowTarget::from(1_000u64), &config),
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(0, 7),
PowTarget::from(1_000u64),
&config
),
PowTarget::from(4_000u64)
);
}
@@ -148,7 +147,11 @@ mod tests {
// divide by the empty block count.
let config = config();
assert_eq!(
compute_epoch_blend_difficulty(0, 0, PowTarget::from(1_000u64), &config),
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(0, 0),
PowTarget::from(1_000u64),
&config
),
PowTarget::from(4_000u64)
);
}
@@ -159,12 +162,20 @@ mod tests {
// A flood far past the clamp: the threshold falls by the factor k and
// no further, so the anonymity set can shrink only gradually.
assert_eq!(
compute_epoch_blend_difficulty(1_000_000, 7, config.base_difficulty, &config),
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(1_000_000, 7),
config.base_difficulty,
&config
),
PowTarget::from(250_000u64)
);
// And symmetrically upwards on a near-empty epoch.
assert_eq!(
compute_epoch_blend_difficulty(1, 7, config.base_difficulty, &config),
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(1, 7),
config.base_difficulty,
&config
),
PowTarget::from(4_000_000u64)
);
}
@@ -176,7 +187,11 @@ mod tests {
// while the clamp bounds how far one epoch may travel towards it.
let config = config();
assert_eq!(
compute_epoch_blend_difficulty(1_000_000, 7, PowTarget::from(1_000u64), &config),
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(1_000_000, 7),
PowTarget::from(1_000u64),
&config
),
PowTarget::from(4_000u64)
);
}
@@ -191,11 +206,11 @@ mod tests {
};
let previous = PowTarget::from(1_234u64);
assert_eq!(
compute_epoch_blend_difficulty(9_999, 7, previous, &config),
compute_epoch_blend_difficulty(ClosedEpochLoad::new(9_999, 7), previous, &config),
previous
);
assert_eq!(
compute_epoch_blend_difficulty(0, 7, previous, &config),
compute_epoch_blend_difficulty(ClosedEpochLoad::new(0, 7), previous, &config),
previous
);
}
@@ -209,7 +224,11 @@ mod tests {
..config()
};
assert_eq!(
compute_epoch_blend_difficulty(280, 7, config.base_difficulty, &config),
compute_epoch_blend_difficulty(
ClosedEpochLoad::new(280, 7),
config.base_difficulty,
&config
),
PowTarget::from(250_000u64)
);
}
@@ -222,7 +241,7 @@ mod tests {
let config = config();
let max_target = -PowTarget::ONE;
assert_eq!(
compute_epoch_blend_difficulty(0, 7, max_target, &config),
compute_epoch_blend_difficulty(ClosedEpochLoad::new(0, 7), max_target, &config),
max_target
);
}
@@ -236,7 +255,8 @@ mod tests {
base_difficulty: base,
..config()
};
let retargeted = compute_epoch_blend_difficulty(280, 7, base, &config);
let retargeted =
compute_epoch_blend_difficulty(ClosedEpochLoad::new(280, 7), base, &config);
// Quadruple load at alpha = 1/2: half the baseline, up to the flooring
// of the square root.
let expected = PowTarget::from(BigUint::from(1u8) << 249);