commit e0ecaced5986cdc07ff47df7614d67f57abf10a7
parent 987a8312b91aaa5209a08c6f5dca5cf387cd8ada
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Mon, 10 Aug 2026 12:34:13 +0200
Aggregate finalizer fees at activation height
Diffstat:
7 files changed, 321 insertions(+), 21 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "iuna"
-version = "0.2.39"
+version = "0.2.40"
dependencies = [
"anyhow",
"axum",
diff --git a/Cargo.toml b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "iuna"
-version = "0.2.39"
+version = "0.2.40"
edition = "2024"
license = "Apache-2.0"
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
@@ -1511,7 +1511,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "iuna-desktop"
-version = "0.2.36"
+version = "0.2.40"
dependencies = [
"tauri",
"tauri-build",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "iuna-desktop"
-version = "0.2.36"
+version = "0.2.40"
edition = "2024"
publish = false
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "iuna",
- "version": "0.2.36",
+ "version": "0.2.40",
"identifier": "labs.iuna.desktop",
"build": {
"beforeDevCommand": "",
diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs
@@ -11,11 +11,12 @@ use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize;
use crate::domain::{
- Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR,
- BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, BlindedTransaction, Block, ChainSnapshot,
- FinalizerMode, LaunchProfile, LeaderProof, Ledger, MINE_REWARD, MaskedBlindedReveal, OutPoint,
- REVEAL_COMMITTEE_SIZE, RevealBundleSection, RevealBundleSignature, Transaction, TxInput,
- TxOutput, blinded_reveal_finalizer_fee, hex_hash, revealed_blinded_transactions,
+ AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT, Amount, BLINDED_COMMITTER_FEE_BPS,
+ BLINDED_FEE_BPS_DENOMINATOR, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal,
+ BlindedTransaction, Block, ChainSnapshot, FinalizerMode, LaunchProfile, LeaderProof, Ledger,
+ MINE_REWARD, MaskedBlindedReveal, OutPoint, REVEAL_COMMITTEE_SIZE, RevealBundleSection,
+ RevealBundleSignature, Transaction, TxInput, TxOutput, blinded_reveal_finalizer_fee, hex_hash,
+ revealed_blinded_transactions,
};
const SCHEMA: &str = r#"
@@ -1257,7 +1258,7 @@ fn metric_index_blinded_fee_outputs(
block.included_reveal_bundle_count(),
available_reveal_bundle_slots,
);
- if reveal_finalizer_fee > 0 {
+ if reveal_finalizer_fee > 0 && block.height < AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT {
utxos.insert(
metric_blinded_executor_fee_outpoint(commitment),
TxOutput {
diff --git a/src/domain.rs b/src/domain.rs
@@ -29,6 +29,7 @@ pub const MINE_DIFFICULTY_BITS: u32 = 12;
pub const MINE_ACTIONS_PER_ANCHOR_LIMIT: usize = 2;
pub const MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT: u64 = 200;
pub const FALLBACK_VDF_RETARGET_ACTIVATION_HEIGHT: u64 = 380;
+pub const AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT: u64 = 795;
pub const MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS: u64 = 20;
pub const REVEAL_COMMITTEE_SIZE: usize = 3;
pub const MAX_REVEAL_BUNDLE_BYTES: usize = 10_000;
@@ -3077,7 +3078,8 @@ impl Ledger {
timestamp_ms,
miner: miner.to_string(),
finalizer_mode: FinalizerMode::Ticket,
- reward: fee_reward(&selection.transactions)?,
+ reward: self
+ .expected_reward_for_next_block(&selection.transactions, &reveal_bundle_section)?,
vdf_rounds: self.vdf_rounds_for_finalizer_rank(finalizer_rank)?,
vdf_seed,
finalizer_rank,
@@ -3148,7 +3150,8 @@ impl Ledger {
miner: miner.to_string(),
finalizer_mode: FinalizerMode::Recovery,
finalizer_rank: 0,
- reward: fee_reward(&selection.transactions)?,
+ reward: self
+ .expected_reward_for_next_block(&selection.transactions, &reveal_bundle_section)?,
vdf_rounds: self.recovery_vdf_rounds()?,
vdf_seed,
leader_ticket: None,
@@ -3212,6 +3215,7 @@ impl Ledger {
let mut utxos = self.utxos.clone();
let mut signatures = BTreeSet::new();
let mut revealed_transactions = Vec::new();
+ let mut aggregated_reveal_finalizer_fees = 0_u64;
for tx in &block.transactions {
if !signatures.insert(tx.signature()) {
bail!("duplicate transaction in block");
@@ -3238,7 +3242,17 @@ impl Ledger {
&tx,
&block.reveal_bundle_section.signatures,
reveal_bundle_slot_count,
+ aggregate_finalizer_fees_active(block.height),
)?;
+ if aggregate_finalizer_fees_active(block.height) {
+ aggregated_reveal_finalizer_fees = aggregated_reveal_finalizer_fees
+ .checked_add(blinded_reveal_finalizer_fee(
+ tx.fee(),
+ block.included_reveal_bundle_count(),
+ reveal_bundle_slot_count,
+ ))
+ .context("aggregated reveal finalizer fees overflow")?;
+ }
revealed_transactions.push(tx);
}
for (commitment, active) in &self.active_blinded {
@@ -3248,7 +3262,8 @@ impl Ledger {
credit_expired_blinded_outputs(&mut utxos, active)?;
}
}
- if block.reward != fee_reward(&block.transactions)? {
+ let expected_reward = block_reward(&block.transactions, aggregated_reveal_finalizer_fees)?;
+ if block.reward != expected_reward {
bail!("block reward is invalid");
}
let mined_signatures = block
@@ -3368,7 +3383,8 @@ impl Ledger {
if block.compute_hash() != block.hash {
bail!("block hash is invalid");
}
- if block.reward != fee_reward(&block.transactions)? {
+ let reveal_bundle_slot_count = self.reveal_committee_for_height(block.height).len();
+ if block.reward != self.expected_reward_for_block(block, reveal_bundle_slot_count)? {
bail!("block reward is invalid");
}
let expected_vdf_rounds = self.expected_vdf_rounds_for_block(block)?;
@@ -3456,6 +3472,67 @@ impl Ledger {
timestamps[timestamps.len() / 2]
}
+ fn expected_reward_for_next_block(
+ &self,
+ transactions: &[Transaction],
+ reveal_bundle_section: &RevealBundleSection,
+ ) -> Result<Amount> {
+ let height = self.tip().height + 1;
+ if !aggregate_finalizer_fees_active(height) {
+ return fee_reward(transactions);
+ }
+ let reveal_bundle_slot_count = self.reveal_committee_for_height(height).len();
+ let aggregate = self.aggregate_reveal_finalizer_fees(
+ height,
+ reveal_bundle_section,
+ reveal_bundle_slot_count,
+ )?;
+ block_reward(transactions, aggregate)
+ }
+
+ fn expected_reward_for_block(
+ &self,
+ block: &Block,
+ reveal_bundle_slot_count: usize,
+ ) -> Result<Amount> {
+ if !aggregate_finalizer_fees_active(block.height) {
+ return fee_reward(&block.transactions);
+ }
+ let aggregate = self.aggregate_reveal_finalizer_fees(
+ block.height,
+ &block.reveal_bundle_section,
+ reveal_bundle_slot_count,
+ )?;
+ block_reward(&block.transactions, aggregate)
+ }
+
+ fn aggregate_reveal_finalizer_fees(
+ &self,
+ height: u64,
+ reveal_bundle_section: &RevealBundleSection,
+ reveal_bundle_slot_count: usize,
+ ) -> Result<Amount> {
+ if !aggregate_finalizer_fees_active(height) {
+ return Ok(0);
+ }
+ reveal_bundle_section
+ .all_reveals()
+ .into_iter()
+ .try_fold(0_u64, |total, reveal| {
+ let active = self
+ .active_blinded
+ .get(&reveal.commitment)
+ .context("blinded reveal does not reference an active blinded transaction")?;
+ total
+ .checked_add(blinded_reveal_finalizer_fee(
+ active.transaction.fee,
+ reveal_bundle_section.included_bundle_count(),
+ reveal_bundle_slot_count,
+ ))
+ .context("aggregated reveal finalizer fees overflow")
+ })
+ }
+
fn next_vdf_rounds_after_tip(&self) -> u64 {
let Some(tip) = self.chain.last() else {
return self.vdf_rounds;
@@ -5230,6 +5307,7 @@ fn credit_blinded_fee_outputs(
transaction: &Transaction,
reveal_bundle_signatures: &[RevealBundleSignature],
available_bundle_slots: usize,
+ aggregate_finalizer_fee: bool,
) -> Result<()> {
let fee = transaction.fee();
if fee == 0 {
@@ -5249,7 +5327,7 @@ fn credit_blinded_fee_outputs(
},
));
}
- if reveal_finalizer_fee > 0 {
+ if reveal_finalizer_fee > 0 && !aggregate_finalizer_fee {
outputs.push((
blinded_executor_fee_outpoint(&active.transaction.commitment),
TxOutput {
@@ -5300,6 +5378,19 @@ fn fee_reward(transactions: &[Transaction]) -> Result<Amount> {
})
}
+fn block_reward(
+ transactions: &[Transaction],
+ aggregated_reveal_finalizer_fees: Amount,
+) -> Result<Amount> {
+ fee_reward(transactions)?
+ .checked_add(aggregated_reveal_finalizer_fees)
+ .context("block reward overflow")
+}
+
+fn aggregate_finalizer_fees_active(height: u64) -> bool {
+ height >= AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT
+}
+
fn spend_inputs(
transaction: &Transaction,
utxos: &mut BTreeMap<OutPoint, TxOutput>,
@@ -6167,6 +6258,19 @@ mod tests {
wallets: &[Wallet],
timestamp_ms: u64,
) -> Block {
+ let block =
+ prepare_preverified_as_next_leader_with_reveal_bundles(ledger, wallets, timestamp_ms);
+ ledger
+ .apply_preverified_block_at(block.clone(), u64::MAX)
+ .unwrap();
+ block
+ }
+
+ fn prepare_preverified_as_next_leader_with_reveal_bundles(
+ ledger: &Ledger,
+ wallets: &[Wallet],
+ timestamp_ms: u64,
+ ) -> Block {
let bundles = ledger
.reveal_committee_for_next_block()
.into_iter()
@@ -6180,11 +6284,18 @@ mod tests {
let prepared = ledger
.prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, bundles)
.unwrap();
- let block = prepared.finish(wallet, "preverified-vdf".to_string());
- ledger
- .apply_preverified_block_at(block.clone(), u64::MAX)
- .unwrap();
- block
+ prepared.finish(wallet, "preverified-vdf".to_string())
+ }
+
+ fn advance_preverified_to_height(ledger: &mut Ledger, wallets: &[Wallet], target_height: u64) {
+ while ledger.height() < target_height {
+ queue_next_leader_burn(ledger, wallets);
+ let timestamp_ms = ledger
+ .tip()
+ .timestamp_ms
+ .saturating_add(VDF_TARGET_BLOCK_MS);
+ mine_preverified_as_next_leader(ledger, wallets, timestamp_ms);
+ }
}
fn queue_next_leader_burn(ledger: &mut Ledger, wallets: &[Wallet]) {
@@ -6400,6 +6511,7 @@ mod tests {
&transaction,
&[],
3,
+ false,
)
.unwrap();
@@ -6442,6 +6554,7 @@ mod tests {
&transaction,
&[],
3,
+ false,
)
.unwrap();
@@ -6504,6 +6617,7 @@ mod tests {
&transaction,
&signatures,
3,
+ false,
)
.unwrap();
@@ -7502,6 +7616,191 @@ mod tests {
}
#[test]
+ fn activated_blinded_reveal_finalizer_fees_are_aggregated_into_block_reward() {
+ let alice = Wallet::from_seed("activated-finalizer-fee-alice");
+ let bob = Wallet::from_seed("activated-finalizer-fee-bob");
+ let carol = Wallet::from_seed("activated-finalizer-fee-carol");
+ let dave = Wallet::from_seed("activated-finalizer-fee-dave");
+ let finalizers = [alice.clone(), bob.clone()];
+ let mut ledger = ledger_with_finalizers(
+ &finalizers,
+ &[(&carol, 10 * MICRO_IUNA), (&dave, 10 * MICRO_IUNA)],
+ );
+ advance_preverified_to_height(
+ &mut ledger,
+ &finalizers,
+ AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 2,
+ );
+ let first_fee = 100;
+ let second_fee = 200;
+ let first_blinded = ledger
+ .build_blinded_burn(&carol, 3, first_fee, ledger.height() + 4)
+ .unwrap();
+ let second_blinded = ledger
+ .build_blinded_burn(&dave, 4, second_fee, ledger.height() + 4)
+ .unwrap();
+ let first_commitment = first_blinded.transaction.commitment.clone();
+ let second_commitment = second_blinded.transaction.commitment.clone();
+ ledger
+ .submit_blinded_transaction(first_blinded.transaction)
+ .unwrap();
+ ledger
+ .submit_blinded_transaction(second_blinded.transaction)
+ .unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
+ let commit_timestamp_ms = ledger
+ .tip()
+ .timestamp_ms
+ .saturating_add(VDF_TARGET_BLOCK_MS);
+ let commit_block =
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, commit_timestamp_ms);
+ assert_eq!(
+ commit_block.height,
+ AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 1
+ );
+
+ ledger.submit_blinded_reveal(first_blinded.reveal).unwrap();
+ ledger.submit_blinded_reveal(second_blinded.reveal).unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
+ let reveal_timestamp_ms = ledger
+ .tip()
+ .timestamp_ms
+ .saturating_add(VDF_TARGET_BLOCK_MS);
+ let reveal_block = prepare_preverified_as_next_leader_with_reveal_bundles(
+ &ledger,
+ &finalizers,
+ reveal_timestamp_ms,
+ );
+ let first_reveal_finalizer_fee = blinded_reveal_finalizer_fee(
+ first_fee,
+ reveal_block.included_reveal_bundle_count(),
+ ledger
+ .burn_leader_ranks_for_block(reveal_block.height)
+ .unwrap()
+ .len(),
+ );
+ let second_reveal_finalizer_fee = blinded_reveal_finalizer_fee(
+ second_fee,
+ reveal_block.included_reveal_bundle_count(),
+ ledger
+ .burn_leader_ranks_for_block(reveal_block.height)
+ .unwrap()
+ .len(),
+ );
+ let aggregate_reveal_finalizer_fee = first_reveal_finalizer_fee
+ .checked_add(second_reveal_finalizer_fee)
+ .unwrap();
+
+ assert_eq!(
+ reveal_block.height,
+ AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT
+ );
+ assert_eq!(reveal_block.reward, aggregate_reveal_finalizer_fee);
+ let mut legacy_reward_block = reveal_block.clone();
+ legacy_reward_block.reward = fee_reward(&legacy_reward_block.transactions).unwrap();
+ legacy_reward_block.hash = legacy_reward_block.compute_hash();
+ let error = ledger
+ .clone()
+ .apply_preverified_block_at(legacy_reward_block, u64::MAX)
+ .unwrap_err();
+ assert!(format!("{error:#}").contains("block reward is invalid"));
+
+ ledger
+ .apply_preverified_block_at(reveal_block.clone(), u64::MAX)
+ .unwrap();
+ assert!(
+ !ledger
+ .utxos
+ .contains_key(&blinded_executor_fee_outpoint(&first_commitment))
+ );
+ assert!(
+ !ledger
+ .utxos
+ .contains_key(&blinded_executor_fee_outpoint(&second_commitment))
+ );
+ assert_eq!(
+ ledger.utxos.get(&reward_outpoint(&reveal_block.hash)),
+ Some(&TxOutput {
+ address: reveal_block.miner.clone(),
+ amount: aggregate_reveal_finalizer_fee,
+ })
+ );
+ }
+
+ #[test]
+ fn pre_activation_blinded_reveal_finalizer_fee_stays_as_executor_utxo_at_boundary() {
+ let alice = Wallet::from_seed("pre-activated-finalizer-fee-alice");
+ let bob = Wallet::from_seed("pre-activated-finalizer-fee-bob");
+ let carol = Wallet::from_seed("pre-activated-finalizer-fee-carol");
+ let finalizers = [alice.clone(), bob.clone()];
+ let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
+ advance_preverified_to_height(
+ &mut ledger,
+ &finalizers,
+ AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 3,
+ );
+ let fee = 100;
+ let blinded = ledger
+ .build_blinded_burn(&carol, 3, fee, ledger.height() + 4)
+ .unwrap();
+ let commitment = blinded.transaction.commitment.clone();
+ ledger
+ .submit_blinded_transaction(blinded.transaction)
+ .unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
+ let commit_timestamp_ms = ledger
+ .tip()
+ .timestamp_ms
+ .saturating_add(VDF_TARGET_BLOCK_MS);
+ let commit_block =
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, commit_timestamp_ms);
+ assert_eq!(
+ commit_block.height,
+ AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 2
+ );
+
+ ledger.submit_blinded_reveal(blinded.reveal).unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
+ let reveal_timestamp_ms = ledger
+ .tip()
+ .timestamp_ms
+ .saturating_add(VDF_TARGET_BLOCK_MS);
+ let reveal_block = mine_preverified_as_next_leader_with_reveal_bundles(
+ &mut ledger,
+ &finalizers,
+ reveal_timestamp_ms,
+ );
+ let reveal_finalizer_fee = blinded_reveal_finalizer_fee(
+ fee,
+ reveal_block.included_reveal_bundle_count(),
+ ledger
+ .burn_leader_ranks_for_block(reveal_block.height)
+ .unwrap()
+ .len(),
+ );
+
+ assert_eq!(
+ reveal_block.height,
+ AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 1
+ );
+ assert_eq!(reveal_block.reward, 0);
+ assert_eq!(
+ ledger
+ .utxos
+ .get(&blinded_executor_fee_outpoint(&commitment)),
+ Some(&TxOutput {
+ address: reveal_block.miner,
+ amount: reveal_finalizer_fee,
+ })
+ );
+ assert!(
+ !ledger
+ .utxos
+ .contains_key(&reward_outpoint(&reveal_block.hash))
+ );
+ }
+
+ #[test]
fn blinded_utxo_commit_exposes_and_locks_inputs_until_reveal_or_expiry() {
let alice = Wallet::from_seed("blinded-lock-alice");
let bob = Wallet::from_seed("blinded-lock-bob");