iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 56b767ff50be459c87404d15c862769921556775
parent fdec4d09beca36acfdfaa5167c7e491867631d73
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed,  5 Aug 2026 11:36:05 +0200

Split blinded fees with reveal list makers

Diffstat:
Mdocs/protocol.md | 4++--
Msrc/adapters/chain_store.rs | 28+++++++++++++++++++---------
Msrc/adapters/http.rs | 46++++++++++++++++++++++++++++++++++++----------
Msrc/domain.rs | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
4 files changed, 171 insertions(+), 69 deletions(-)

diff --git a/docs/protocol.md b/docs/protocol.md @@ -50,7 +50,7 @@ Every normal block must include at least one plaintext burn. A blinded transacti This mandatory burn is a liveness rule for the ticket pool, not a fairness rule for ticket distribution. It guarantees that normal block production keeps creating future tickets. Fairness against self-serving finalizers comes from blinded third-party burns. -Plaintext transaction fees go to the block finalizer immediately. Blinded transaction fees are paid when the payload is revealed and executed. Half goes to the finalizer that originally committed the envelope. The other half is the executor share and depends on reveal-bundle participation: with `0`, `1`, `2`, or `3` included reveal bundles, the reveal-block finalizer receives `0/3`, `1/3`, `2/3`, or `3/3` of that executor share. Any missing executor share is burned. +Wallet-created transactions are not gossiped as plaintext. Their fees are paid when the blinded payload is revealed and executed: `35%` goes to the finalizer that originally committed the envelope, `35%` goes to the reveal-block finalizer, and `10%` goes to each included signed reveal-list maker. Missing reveal-list shares and rounding dust are burned. The locally produced plaintext burn required for block liveness is part of the block reward like other plaintext block items. ## VDF Timing @@ -153,7 +153,7 @@ If a slot has no included bundle, it contributes a fixed default hash for that s When a valid bundled reveal executes, nodes decrypt the earlier payload, check the commitment and payload hash, decode the normal transaction, validate it against the current UTXO set, and execute it once. If the reveal bitmask says multiple committee bundles contained the same reveal, the reveal is still executed only once. If the decrypted transaction is a burn, it creates burn tickets at the reveal height, not the earlier envelope-commit height. -Fees are paid without inflating the reveal block reward. The decrypted transaction must pay the same fee declared by the blinded envelope. `floor(fee / 2)` goes to the envelope committer. The reveal-block finalizer can receive up to the remaining executor share, scaled by the number of included reveal bundles. Missing executor share is burned instead of redistributed. +Fees are paid without inflating the reveal block reward. The decrypted transaction must pay the same fee declared by the blinded envelope. `35%` goes to the envelope committer, `35%` goes to the reveal-block finalizer, and `10%` goes to each included signed reveal-list maker. Missing reveal-list shares and rounding dust are burned instead of redistributed. Expiry is exclusive: a blinded envelope with expiry height `H` can be included only in blocks below height `H`, and revealed only while the current chain height is below `H`. The expiry height must be within `20` blocks of the node's current chain height when the envelope is accepted or selected. Expired envelopes and reveals are dropped from local selection. diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -10,9 +10,11 @@ use rusqlite::{Connection, OptionalExtension, params}; use serde::Serialize; use crate::domain::{ - Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, FinalizerMode, LaunchProfile, - LeaderProof, Ledger, MINE_REWARD, MaskedBlindedReveal, OutPoint, RevealBundleSection, - RevealBundleSignature, Transaction, TxInput, TxOutput, revealed_blinded_transactions, + Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, + BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BLINDED_REVEAL_FINALIZER_FEE_BPS, BlindedReveal, + BlindedTransaction, Block, ChainSnapshot, FinalizerMode, LaunchProfile, LeaderProof, Ledger, + MINE_REWARD, MaskedBlindedReveal, OutPoint, RevealBundleSection, RevealBundleSignature, + Transaction, TxInput, TxOutput, revealed_blinded_transactions, }; const SCHEMA: &str = r#" @@ -505,6 +507,10 @@ fn decode_blinded_reveal(reader: &mut CompactReader<'_>) -> Result<BlindedReveal }) } +fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { + ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount +} + fn encode_reveal_bundle_section( writer: &mut CompactWriter, section: &RevealBundleSection, @@ -934,13 +940,17 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> fees_amount = fees_amount .checked_add(transaction.fee()) .context("block metric fees overflow")?; - let committer_fee = transaction.fee() / 2; - let executor_full_fee = transaction.fee() - committer_fee; - let executor_fee = executor_full_fee - .saturating_mul(block.included_reveal_bundle_count() as u64) - / crate::domain::REVEAL_COMMITTEE_SIZE as u64; + let committer_fee = blinded_fee_share(transaction.fee(), BLINDED_COMMITTER_FEE_BPS); + let reveal_finalizer_fee = + blinded_fee_share(transaction.fee(), BLINDED_REVEAL_FINALIZER_FEE_BPS); + let reveal_bundle_signer_fees = + blinded_fee_share(transaction.fee(), BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS) + .saturating_mul(block.included_reveal_bundle_count() as u64); + let distributed_fee = committer_fee + .saturating_add(reveal_finalizer_fee) + .saturating_add(reveal_bundle_signer_fees); burned_fee_amount = burned_fee_amount - .checked_add(executor_full_fee.saturating_sub(executor_fee)) + .checked_add(transaction.fee().saturating_sub(distributed_fee)) .context("block metric burned fees overflow")?; match transaction { Transaction::Transfer { .. } => transfer_count += 1, diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -33,9 +33,11 @@ use crate::{ FeeEstimate, NodeStatus, PeerDirection, PeerInfo, SharedNode, SharedPeerBook, StratumStatus, }, domain::{ - Amount, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, Ledger, - MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, RevealedBlindedTransaction, Transaction, - TxInput, TxOutput, Wallet, hex_hash, revealed_blinded_transactions, + Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, + BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BLINDED_REVEAL_FINALIZER_FEE_BPS, BlindedReveal, + BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, Ledger, MINE_FINALIZER_FEE, + MINE_REWARD, OutPoint, RevealedBlindedTransaction, Transaction, TxInput, TxOutput, Wallet, + hex_hash, revealed_blinded_transactions, }, }; @@ -2094,7 +2096,7 @@ fn known_output_index( index_transaction_outputs(&mut outputs, &revealed.transaction); let fee = revealed.transaction.fee(); if fee > 0 { - let committer_fee = fee / 2; + let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); if committer_fee > 0 { outputs.insert( blinded_committer_fee_outpoint(&revealed.commitment), @@ -2105,19 +2107,32 @@ fn known_output_index( ); } if let Some(block) = blocks_by_height.get(&revealed.height) { - let executor_full_fee = fee - committer_fee; - let executor_fee = executor_full_fee - .saturating_mul(block.included_reveal_bundle_count() as u64) - / crate::domain::REVEAL_COMMITTEE_SIZE as u64; - if executor_fee > 0 { + let reveal_finalizer_fee = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS); + if reveal_finalizer_fee > 0 { outputs.insert( blinded_executor_fee_outpoint(&revealed.commitment), TxOutput { address: block.miner.clone(), - amount: executor_fee, + amount: reveal_finalizer_fee, }, ); } + let reveal_bundle_signer_fee = + blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); + if reveal_bundle_signer_fee > 0 { + for signature in &block.reveal_bundle_section.signatures { + outputs.insert( + blinded_reveal_bundle_signer_fee_outpoint( + &revealed.commitment, + signature.slot, + ), + TxOutput { + address: signature.member.clone(), + amount: reveal_bundle_signer_fee, + }, + ); + } + } } } } @@ -2178,6 +2193,17 @@ fn blinded_executor_fee_outpoint(commitment: &str) -> OutPoint { } } +fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: u32::MAX - 3 - u32::from(slot), + } +} + +fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { + ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount +} + async fn replace_setup_wallet_with_generated_seed( state: &HttpState, headers: &HeaderMap, diff --git a/src/domain.rs b/src/domain.rs @@ -29,6 +29,10 @@ pub const MINE_DIFFICULTY_BITS: u32 = 12; 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; +pub const BLINDED_FEE_BPS_DENOMINATOR: u64 = 10_000; +pub const BLINDED_COMMITTER_FEE_BPS: u64 = 3_500; +pub const BLINDED_REVEAL_FINALIZER_FEE_BPS: u64 = 3_500; +pub const BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS: u64 = 1_000; const MINE_RETARGET_WINDOW_BLOCKS: u64 = 10; const MINE_TARGET_ACTIONS_PER_BLOCK: u64 = 1; const MINE_MAX_RETARGET_STEP_BITS: u32 = 2; @@ -2940,7 +2944,6 @@ impl Ledger { apply_transaction(tx, &mut utxos)?; } let mut revealed_commitments = BTreeSet::new(); - let included_reveal_bundle_count = block.included_reveal_bundle_count(); for reveal in block.all_blinded_reveals() { if !revealed_commitments.insert(reveal.commitment.clone()) { bail!("duplicate blinded reveal in block"); @@ -2956,7 +2959,7 @@ impl Ledger { active, &block.miner, &tx, - included_reveal_bundle_count, + &block.reveal_bundle_section.signatures, )?; revealed_transactions.push(tx); } @@ -4613,16 +4616,15 @@ fn credit_blinded_fee_outputs( active: &ActiveBlindedTransaction, reveal_executor: &str, transaction: &Transaction, - included_reveal_bundle_count: usize, + reveal_bundle_signatures: &[RevealBundleSignature], ) -> Result<()> { let fee = transaction.fee(); if fee == 0 { return Ok(()); } - let committer_fee = fee / 2; - let executor_full_fee = fee - committer_fee; - let executor_fee = executor_full_fee.saturating_mul(included_reveal_bundle_count as u64) - / REVEAL_COMMITTEE_SIZE as u64; + let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); + let reveal_finalizer_fee = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS); + let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); let mut outputs = Vec::new(); if committer_fee > 0 { outputs.push(( @@ -4633,15 +4635,29 @@ fn credit_blinded_fee_outputs( }, )); } - if executor_fee > 0 { + if reveal_finalizer_fee > 0 { outputs.push(( blinded_executor_fee_outpoint(&active.transaction.commitment), TxOutput { address: reveal_executor.to_string(), - amount: executor_fee, + amount: reveal_finalizer_fee, }, )); } + for signature in reveal_bundle_signatures { + if reveal_bundle_signer_fee > 0 { + outputs.push(( + blinded_reveal_bundle_signer_fee_outpoint( + &active.transaction.commitment, + signature.slot, + ), + TxOutput { + address: signature.member.clone(), + amount: reveal_bundle_signer_fee, + }, + )); + } + } let tx_outputs = outputs .iter() .map(|(_, output)| output.clone()) @@ -4653,6 +4669,10 @@ fn credit_blinded_fee_outputs( Ok(()) } +fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { + ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount +} + fn fee_reward(transactions: &[Transaction]) -> Result<Amount> { transactions.iter().try_fold(0_u64, |total, tx| { total.checked_add(tx.fee()).context("block fees overflow") @@ -4849,6 +4869,13 @@ fn blinded_executor_fee_outpoint(commitment: &str) -> OutPoint { } } +fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: u32::MAX - 3 - u32::from(slot), + } +} + fn validate_genesis_block(block: &Block) -> Result<()> { if block.height != 0 { bail!("genesis block height must be 0"); @@ -5543,7 +5570,7 @@ mod tests { } #[test] - fn blinded_fee_split_rounds_remainder_to_reveal_executor() { + fn blinded_fee_split_burns_rounding_dust() { let committer = Wallet::from_seed("blinded-split-committer"); let executor = Wallet::from_seed("blinded-split-executor"); let commitment = "01".repeat(32); @@ -5568,29 +5595,19 @@ mod tests { }; let mut utxos = BTreeMap::new(); - credit_blinded_fee_outputs( - &mut utxos, - &active, - executor.address(), - &transaction, - REVEAL_COMMITTEE_SIZE, - ) - .unwrap(); + credit_blinded_fee_outputs(&mut utxos, &active, executor.address(), &transaction, &[]) + .unwrap(); assert!(!utxos.contains_key(&blinded_committer_fee_outpoint(&commitment))); - assert_eq!( - utxos.get(&blinded_executor_fee_outpoint(&commitment)), - Some(&TxOutput { - address: executor.address().to_string(), - amount: 1, - }) - ); + assert!(!utxos.contains_key(&blinded_executor_fee_outpoint(&commitment))); } #[test] - fn blinded_executor_fee_scales_with_included_reveal_bundles() { + fn blinded_fee_split_pays_committer_executor_and_reveal_bundle_signers() { let committer = Wallet::from_seed("blinded-scale-committer"); let executor = Wallet::from_seed("blinded-scale-executor"); + let signer_a = Wallet::from_seed("blinded-scale-signer-a"); + let signer_b = Wallet::from_seed("blinded-scale-signer-b"); let commitment = "05".repeat(32); let active = ActiveBlindedTransaction { transaction: BlindedTransaction { @@ -5608,26 +5625,58 @@ mod tests { let transaction = Transaction::Transfer { inputs: Vec::new(), outputs: Vec::new(), - fee: 7, + fee: 100, signature: String::new(), }; let mut utxos = BTreeMap::new(); + let signatures = vec![ + RevealBundleSignature { + slot: 0, + member: signer_a.address().to_string(), + signature: "11".repeat(SIGNATURE_BYTES), + }, + RevealBundleSignature { + slot: 2, + member: signer_b.address().to_string(), + signature: "22".repeat(SIGNATURE_BYTES), + }, + ]; - credit_blinded_fee_outputs(&mut utxos, &active, executor.address(), &transaction, 1) - .unwrap(); + credit_blinded_fee_outputs( + &mut utxos, + &active, + executor.address(), + &transaction, + &signatures, + ) + .unwrap(); assert_eq!( utxos.get(&blinded_committer_fee_outpoint(&commitment)), Some(&TxOutput { address: committer.address().to_string(), - amount: 3, + amount: 35, }) ); assert_eq!( utxos.get(&blinded_executor_fee_outpoint(&commitment)), Some(&TxOutput { address: executor.address().to_string(), - amount: 1, + amount: 35, + }) + ); + assert_eq!( + utxos.get(&blinded_reveal_bundle_signer_fee_outpoint(&commitment, 0)), + Some(&TxOutput { + address: signer_a.address().to_string(), + amount: 10, + }) + ); + assert_eq!( + utxos.get(&blinded_reveal_bundle_signer_fee_outpoint(&commitment, 2)), + Some(&TxOutput { + address: signer_b.address().to_string(), + amount: 10, }) ); } @@ -6358,7 +6407,7 @@ mod tests { let carol = Wallet::from_seed("blinded-burn-carol"); let finalizers = [alice.clone(), bob.clone()]; let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let fee = 7; + let fee = 100; let burn_amount = 3; let before_carol = ledger.balance_of(carol.address()); @@ -6412,16 +6461,14 @@ mod tests { .fold(0_u64, |total, transaction| { total + transaction.amount() + transaction.fee() }); - let committer_fee = fee / 2; - let executor_fee = (fee - committer_fee) - * reveal_block.included_reveal_bundle_count() as u64 - / REVEAL_COMMITTEE_SIZE as u64; + let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); + let reveal_finalizer_fee = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS); + let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); + let commitment = &commit_block.blinded_transactions[0].commitment; assert_eq!( ledger .utxos - .get(&blinded_committer_fee_outpoint( - &commit_block.blinded_transactions[0].commitment - )) + .get(&blinded_committer_fee_outpoint(commitment)) .unwrap(), &TxOutput { address: inclusion_finalizer.clone(), @@ -6431,20 +6478,39 @@ mod tests { assert_eq!( ledger .utxos - .get(&blinded_executor_fee_outpoint( - &commit_block.blinded_transactions[0].commitment - )) + .get(&blinded_executor_fee_outpoint(commitment)) .unwrap(), &TxOutput { address: reveal_executor.clone(), - amount: executor_fee, + amount: reveal_finalizer_fee, } ); - let inclusion_finalizer_fee = if inclusion_finalizer == reveal_executor { - committer_fee + executor_fee - } else { - committer_fee - }; + for signature in &reveal_block.reveal_bundle_section.signatures { + assert_eq!( + ledger + .utxos + .get(&blinded_reveal_bundle_signer_fee_outpoint( + commitment, + signature.slot + )) + .unwrap(), + &TxOutput { + address: signature.member.clone(), + amount: reveal_bundle_signer_fee, + } + ); + } + let mut inclusion_finalizer_fee = committer_fee; + if inclusion_finalizer == reveal_executor { + inclusion_finalizer_fee += reveal_finalizer_fee; + } + inclusion_finalizer_fee += reveal_block + .reveal_bundle_section + .signatures + .iter() + .filter(|signature| signature.member == inclusion_finalizer) + .count() as u64 + * reveal_bundle_signer_fee; assert_eq!( ledger.balance_of(&inclusion_finalizer), before_inclusion_finalizer + inclusion_finalizer_fee