commit 0471bb795e25c6f56d270265d41598e950b918e4
parent 388dc361137e4e46d39e8ceab2199860852ad24a
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Fri, 14 Aug 2026 15:02:18 +0200
Gate reveal fee mask attribution at height 750
Diffstat:
5 files changed, 130 insertions(+), 13 deletions(-)
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -156,16 +156,22 @@ Validators reconstruct each signed committee bundle from this compact section be
A block may contain at most one bundle per slot. If a node sees two different signed bundles for the same height and slot before block assembly, it treats that slot as locally equivocated and does not use either bundle for that round.
-The block VDF seed is bound to the reveal bundle hashes:
+The ticket-block VDF seed is bound to the reveal bundle hashes:
`seed = hash(parent hash || height || bundle_hash[0] || bundle_hash[1] || bundle_hash[2])`
+Recovery blocks additionally bind the block timestamp into the VDF seed:
+
+`seed = hash(parent hash || height || timestamp_ms || bundle_hash[0] || bundle_hash[1] || bundle_hash[2])`
+
If a slot has no included bundle, it contributes a fixed default hash for that slot. This means the finalizer must choose the reveal-bundle set before doing the VDF work. A finalizer can still claim that a bundle arrived too late, but it cannot secretly swap or remove a timely bundle after computing the VDF without changing the seed.
When a valid bundled reveal executes, nodes decrypt the earlier payload, check the commitment and payload hash, and decode the transfer or burn. The decrypted transaction inputs must match the visible inputs locked by the envelope, and the transaction executes against that locked value. 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. `35%` goes to the envelope committer. Up to `35%` goes to the reveal-block finalizer, scaled by the included signed reveal lists divided by the available committee slots for that height. With three eligible slots, one included list pays one third of that share; with two eligible slots, one included list pays half; with one eligible slot, one included list pays the full share. `10%` goes to each included signed reveal-list maker. Missing reveal-list shares, the missing reveal-finalizer share, and rounding dust are burned instead of redistributed.
+Starting at height `750`, reveal fee attribution is per reveal mask. A signed reveal-list maker earns the `10%` share for a revealed payload only if that maker's signed bundle actually contained that reveal. The reveal-block finalizer's scaled share is also based on the number of signed bundles that contained that reveal, not merely the number of bundle signatures included somewhere in the block. Before height `750`, all included signed reveal-list makers are treated as participating in every revealed payload in that block.
+
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. If an envelope expires unrevealed, its declared fee is burned and any remaining locked value returns as deterministic change to the owner of the first visible input. Expired local envelopes and reveals are dropped from local selection.
This does not make censorship impossible. A finalizer can still ignore all blinded traffic, or censor based on network metadata. But it removes the cheap strategy of inspecting plaintext mempool transactions and excluding third-party burns while including other fee-paying transactions.
@@ -194,6 +200,12 @@ When a node builds a block, it selects transactions in this order:
Blocks are bounded by transaction count and serialized byte size. The devnet maximum block size is `100,000` bytes.
+## Fork Choice
+
+Nodes fully validate candidate blocks or snapshots before considering a reorg. A candidate chain must share the same genesis and cannot rewrite history deeper than the finality depth. In the devnet profile, forks whose common ancestor is below `local height - 6` are rejected.
+
+Within that finality window, a taller valid candidate chain wins over the local chain. If the candidate and local chains have the same height but different tips, nodes compare the first divergent blocks by leader score: ticket blocks beat recovery blocks, lower finalizer rank beats higher rank, and the leader proof rank breaks remaining ties. Equal quality keeps the local chain.
+
## Genesis and Joining
Genesis is explicit. A normal node without a chain starts in setup mode and waits to join an existing chain from peers rather than silently creating a separate chain.
diff --git a/src/domain.rs b/src/domain.rs
@@ -50,6 +50,8 @@ pub use hex::hex_hash;
use hex::{decode_hex, decode_hex_array, hex_encode};
pub use history::revealed_blinded_transactions;
#[cfg(test)]
+use ledger_apply::{reveal_fee_bundle_count_for_height, reveal_fee_signatures_for_height};
+#[cfg(test)]
use ledger_ops::estimated_block_selection_size_bytes;
#[cfg(test)]
use ledger_ops::fee_reward;
@@ -77,8 +79,8 @@ pub use protocol::{
MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MAX_BLOCK_BYTES, MAX_PENDING_TRANSACTIONS,
MAX_REVEAL_BUNDLE_BYTES, MAX_VDF_ROUNDS, MICRO_IUNA, MINE_ACTIONS_PER_ANCHOR_LIMIT,
MINE_DIFFICULTY_BITS, MINE_FINALIZER_FEE, MINE_REWARD, RECOVERY_BLOCK_DELAY_MS,
- REVEAL_COMMITTEE_SIZE, TransactionSubmitOutcome, UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT,
- VDF_TARGET_BLOCK_MS,
+ REVEAL_COMMITTEE_SIZE, REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, TransactionSubmitOutcome,
+ UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT, VDF_TARGET_BLOCK_MS,
};
use protocol::{
BLINDED_KEY_BYTES, BLINDED_NONCE_BYTES, BLOCK_MEDIAN_TIME_PAST_WINDOW,
diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs
@@ -18,8 +18,9 @@ use super::ticket::{
use super::transaction::{blinded_transaction_inputs_available, transaction_inputs_available};
use super::{
Amount, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block, FinalizerMode, Ledger,
- MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, RevealBundleSection, Transaction,
- blinded_reveal_finalizer_fee, unix_now_ms, verify_vdf,
+ MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, MaskedBlindedReveal, REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT,
+ RevealBundleSection, RevealBundleSignature, Transaction, blinded_reveal_finalizer_fee,
+ unix_now_ms, verify_vdf,
};
impl Ledger {
@@ -86,7 +87,8 @@ impl Ledger {
apply_transaction(tx, &mut utxos)?;
}
let mut revealed_commitments = BTreeSet::new();
- for reveal in block.all_blinded_reveals() {
+ for masked in &block.reveal_bundle_section.reveals {
+ let reveal = &masked.reveal;
if !revealed_commitments.insert(reveal.commitment.clone()) {
bail!("duplicate blinded reveal in block");
}
@@ -97,19 +99,28 @@ impl Ledger {
.clone();
let tx = self.decrypt_active_blinded(&active, reveal)?;
self.apply_revealed_blinded_transaction(&active, &tx, &mut utxos)?;
+ let reveal_bundle_signatures = reveal_fee_signatures_for_height(
+ block.height,
+ &block.reveal_bundle_section,
+ masked,
+ );
credit_blinded_fee_outputs(
&mut utxos,
&active,
&block.miner,
&tx,
- &block.reveal_bundle_section.signatures,
+ &reveal_bundle_signatures,
reveal_bundle_slot_count,
true,
)?;
aggregated_reveal_finalizer_fees = aggregated_reveal_finalizer_fees
.checked_add(blinded_reveal_finalizer_fee(
tx.fee(),
- block.included_reveal_bundle_count(),
+ reveal_fee_bundle_count_for_height(
+ block.height,
+ &block.reveal_bundle_section,
+ masked,
+ ),
reveal_bundle_slot_count,
))
.context("aggregated reveal finalizer fees overflow")?;
@@ -362,20 +373,51 @@ impl Ledger {
reveal_bundle_slot_count: usize,
) -> Result<Amount> {
reveal_bundle_section
- .all_reveals()
- .into_iter()
- .try_fold(0_u64, |total, reveal| {
+ .reveals
+ .iter()
+ .try_fold(0_u64, |total, masked| {
let active = self
.active_blinded
- .get(&reveal.commitment)
+ .get(&masked.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_fee_bundle_count_for_height(
+ self.tip().height + 1,
+ reveal_bundle_section,
+ masked,
+ ),
reveal_bundle_slot_count,
))
.context("aggregated reveal finalizer fees overflow")
})
}
}
+
+pub(super) fn reveal_fee_bundle_count_for_height(
+ height: u64,
+ section: &RevealBundleSection,
+ masked: &MaskedBlindedReveal,
+) -> usize {
+ reveal_fee_signatures_for_height(height, section, masked).len()
+}
+
+pub(super) fn reveal_fee_signatures_for_height(
+ height: u64,
+ section: &RevealBundleSection,
+ masked: &MaskedBlindedReveal,
+) -> Vec<RevealBundleSignature> {
+ if height < REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT {
+ return section.signatures.clone();
+ }
+ section
+ .signatures
+ .iter()
+ .filter(|signature| {
+ 1_u8.checked_shl(u32::from(signature.slot))
+ .is_some_and(|slot_mask| masked.bundle_mask & slot_mask != 0)
+ })
+ .cloned()
+ .collect()
+}
diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs
@@ -21,6 +21,7 @@ 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;
+pub const REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT: u64 = 750;
pub const MAX_PENDING_TRANSACTIONS: usize = 10_000;
pub(super) const MAX_ORPHAN_TRANSACTIONS: usize = 1_024;
diff --git a/src/domain/tests.rs b/src/domain/tests.rs
@@ -614,6 +614,66 @@ fn blinded_reveal_finalizer_fee_scales_by_available_reveal_bundle_slots() {
}
#[test]
+fn reveal_fee_attribution_uses_reveal_mask_from_height_750() {
+ let alice = Wallet::from_seed("mask-fee-alice");
+ let bob = Wallet::from_seed("mask-fee-bob");
+ let carol = Wallet::from_seed("mask-fee-carol");
+ let reveal = MaskedBlindedReveal {
+ reveal: BlindedReveal {
+ commitment: hex_hash("mask-fee-reveal"),
+ key: "00".repeat(BLINDED_KEY_BYTES),
+ },
+ bundle_mask: 0b0000_0101,
+ };
+ let section = RevealBundleSection {
+ signatures: vec![
+ RevealBundleSignature {
+ slot: 0,
+ member: alice.address().to_string(),
+ signature: "11".repeat(SIGNATURE_BYTES),
+ },
+ RevealBundleSignature {
+ slot: 1,
+ member: bob.address().to_string(),
+ signature: "22".repeat(SIGNATURE_BYTES),
+ },
+ RevealBundleSignature {
+ slot: 2,
+ member: carol.address().to_string(),
+ signature: "33".repeat(SIGNATURE_BYTES),
+ },
+ ],
+ reveals: vec![reveal.clone()],
+ };
+
+ let legacy_signers =
+ reveal_fee_signatures_for_height(REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT - 1, §ion, &reveal);
+ assert_eq!(legacy_signers.len(), 3);
+ assert_eq!(
+ reveal_fee_bundle_count_for_height(
+ REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT - 1,
+ §ion,
+ &reveal
+ ),
+ 3
+ );
+
+ let masked_signers =
+ reveal_fee_signatures_for_height(REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, §ion, &reveal);
+ assert_eq!(
+ masked_signers
+ .iter()
+ .map(|signature| signature.member.as_str())
+ .collect::<Vec<_>>(),
+ vec![alice.address(), carol.address()]
+ );
+ assert_eq!(
+ reveal_fee_bundle_count_for_height(REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, §ion, &reveal),
+ 2
+ );
+}
+
+#[test]
fn transfer_rejects_invalid_recipient_address() {
let alice = Wallet::from_seed("invalid-transfer-recipient-alice");
let ledger = ledger_with_wallet_utxos(&alice, &[10]);