commit 21490a3adc6125732f338a19b6d400e250af20c5
parent 0b7dfff14e34762c9eb852e27536d013283711d8
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 5 Aug 2026 11:17:28 +0200
Compact reveal bundle storage
Diffstat:
5 files changed, 401 insertions(+), 98 deletions(-)
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -133,7 +133,15 @@ Reveal is a later step. A `BlindedReveal` carries only the commitment and decryp
For each next block height, nodes compute a reveal committee from the burn leader ranking. The last three ranked eligible tickets form the three reveal-bundle slots. A committee member can sign one bundle for its slot, height, and parent hash. A bundle is at most `10,000` bytes and lists valid pending reveals ordered by visible fee rate. Empty bundles are not gossiped.
-A block has an envelope section and up to three reveal-bundle sections in fixed slot order. The envelope section contains the finalizer's plaintext burn, other plaintext block items, and blinded transaction envelopes. The bundle sections contain reveals selected by the committee members.
+A block has an envelope section and one compact reveal-bundle section. The envelope section contains the finalizer's plaintext burn, other plaintext block items, and blinded transaction envelopes.
+
+The compact reveal-bundle section stores:
+
+- up to three bundle signatures, one per committee slot, in slot order;
+- one deduplicated reveal list;
+- a small bitmask per reveal saying which of the included committee bundles contained that reveal.
+
+Validators reconstruct each signed committee bundle from this compact section before checking signatures, bundle size, slot assignment, and fee ordering. This keeps consensus bound to the three independent signed reveal lists without storing the same reveal payload multiple times when several committee members selected it.
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.
@@ -143,7 +151,7 @@ The block VDF seed is bound to the reveal bundle hashes:
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, decode the normal transaction, validate it against the current UTXO set, and execute it once. If multiple committee bundles contain 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.
+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.
diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs
@@ -11,8 +11,8 @@ use serde::Serialize;
use crate::domain::{
Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, FinalizerMode, LaunchProfile,
- LeaderProof, Ledger, MINE_REWARD, OutPoint, RevealBundle, Transaction, TxInput, TxOutput,
- revealed_blinded_transactions,
+ LeaderProof, Ledger, MINE_REWARD, MaskedBlindedReveal, OutPoint, RevealBundleSection,
+ RevealBundleSignature, Transaction, TxInput, TxOutput, revealed_blinded_transactions,
};
const SCHEMA: &str = r#"
@@ -299,7 +299,7 @@ fn clear_metrics_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Resu
}
const COMPACT_SNAPSHOT_MAGIC: &[u8] = b"IUNA-SNAPSHOT";
-const COMPACT_SNAPSHOT_VERSION: u8 = 2;
+const COMPACT_SNAPSHOT_VERSION: u8 = 3;
fn encode_compact_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<u8>> {
let mut writer = CompactWriter::default();
@@ -410,10 +410,7 @@ fn encode_block_body(writer: &mut CompactWriter, block: &Block) -> Result<()> {
for transaction in &block.blinded_transactions {
encode_blinded_transaction(writer, transaction)?;
}
- writer.varint(block.reveal_bundles.len() as u64);
- for bundle in &block.reveal_bundles {
- encode_reveal_bundle(writer, bundle)?;
- }
+ encode_reveal_bundle_section(writer, &block.reveal_bundle_section)?;
writer.varint(block.transactions.len() as u64);
for transaction in &block.transactions {
encode_transaction(writer, transaction)?;
@@ -448,7 +445,7 @@ fn decode_block_body(
None
};
let blinded_transactions = decode_vec(reader, decode_blinded_transaction)?;
- let reveal_bundles = decode_vec(reader, decode_reveal_bundle)?;
+ let reveal_bundle_section = decode_reveal_bundle_section(reader)?;
let transactions = decode_vec(reader, decode_transaction)?;
let hash = reader.hex()?;
Ok(Block {
@@ -463,7 +460,7 @@ fn decode_block_body(
vdf_output,
leader_proof,
blinded_transactions,
- reveal_bundles,
+ reveal_bundle_section,
transactions,
hash,
})
@@ -508,27 +505,41 @@ fn decode_blinded_reveal(reader: &mut CompactReader<'_>) -> Result<BlindedReveal
})
}
-fn encode_reveal_bundle(writer: &mut CompactWriter, bundle: &RevealBundle) -> Result<()> {
- writer.varint(bundle.height);
- writer.hex(&bundle.prev_hash)?;
- writer.varint(u64::from(bundle.slot));
- writer.hex(&bundle.member)?;
- writer.varint(bundle.reveals.len() as u64);
- for reveal in &bundle.reveals {
- encode_blinded_reveal(writer, reveal)?;
+fn encode_reveal_bundle_section(
+ writer: &mut CompactWriter,
+ section: &RevealBundleSection,
+) -> Result<()> {
+ writer.varint(section.signatures.len() as u64);
+ for signature in §ion.signatures {
+ writer.varint(u64::from(signature.slot));
+ writer.hex(&signature.member)?;
+ writer.hex(&signature.signature)?;
+ }
+ writer.varint(section.reveals.len() as u64);
+ for masked in §ion.reveals {
+ encode_blinded_reveal(writer, &masked.reveal)?;
+ writer.u8(masked.bundle_mask);
}
- writer.hex(&bundle.signature)?;
Ok(())
}
-fn decode_reveal_bundle(reader: &mut CompactReader<'_>) -> Result<RevealBundle> {
- Ok(RevealBundle {
- height: reader.varint()?,
- prev_hash: reader.hex()?,
- slot: u8::try_from(reader.varint()?).context("reveal bundle slot does not fit u8")?,
- member: reader.hex()?,
- reveals: decode_vec(reader, decode_blinded_reveal)?,
- signature: reader.hex()?,
+fn decode_reveal_bundle_section(reader: &mut CompactReader<'_>) -> Result<RevealBundleSection> {
+ let signatures = decode_vec(reader, |reader| {
+ Ok(RevealBundleSignature {
+ slot: u8::try_from(reader.varint()?).context("reveal bundle slot does not fit u8")?,
+ member: reader.hex()?,
+ signature: reader.hex()?,
+ })
+ })?;
+ let reveals = decode_vec(reader, |reader| {
+ Ok(MaskedBlindedReveal {
+ reveal: decode_blinded_reveal(reader)?,
+ bundle_mask: reader.u8()?,
+ })
+ })?;
+ Ok(RevealBundleSection {
+ signatures,
+ reveals,
})
}
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -1834,8 +1834,9 @@ fn ui_block(
.map(|revealed| (revealed.commitment.clone(), revealed.transaction.clone()))
.collect::<BTreeMap<_, _>>();
let reveal_bundles = block
- .reveal_bundles
- .iter()
+ .reveal_bundle_section
+ .expand(block.height, &block.prev_hash)
+ .into_iter()
.map(|bundle| UiRevealBundle {
slot: bundle.slot,
member: bundle.member.clone(),
@@ -4046,7 +4047,8 @@ mod tests {
app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus},
domain::{
Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, LaunchProfile, Ledger,
- MICRO_IUNA, MINE_FINALIZER_FEE, OutPoint, Transaction, Wallet,
+ MICRO_IUNA, MINE_FINALIZER_FEE, MaskedBlindedReveal, OutPoint, RevealBundleSection,
+ RevealBundleSignature, Transaction, Wallet,
},
};
@@ -4145,14 +4147,17 @@ mod tests {
let mut commit_block = fake_block(7, Vec::new());
commit_block.blinded_transactions = vec![built.transaction.clone()];
let mut reveal_block = fake_block(8, Vec::new());
- reveal_block.reveal_bundles = vec![crate::domain::RevealBundle {
- height: reveal_block.height,
- prev_hash: reveal_block.prev_hash.clone(),
- slot: 0,
- member: reveal_block.miner.clone(),
- reveals: vec![built.reveal.clone()],
- signature: "11".repeat(64),
- }];
+ reveal_block.reveal_bundle_section = RevealBundleSection {
+ signatures: vec![RevealBundleSignature {
+ slot: 0,
+ member: reveal_block.miner.clone(),
+ signature: "11".repeat(64),
+ }],
+ reveals: vec![MaskedBlindedReveal {
+ reveal: built.reveal.clone(),
+ bundle_mask: 1,
+ }],
+ };
let snapshot = fake_snapshot(
allocations,
vec![commit_block.clone(), reveal_block.clone()],
@@ -5009,14 +5014,17 @@ mod tests {
let mut commit_block = fake_block(7, Vec::new());
commit_block.blinded_transactions = vec![built.transaction];
let mut reveal_block = fake_block(8, Vec::new());
- reveal_block.reveal_bundles = vec![crate::domain::RevealBundle {
- height: reveal_block.height,
- prev_hash: reveal_block.prev_hash.clone(),
- slot: 0,
- member: reveal_block.miner.clone(),
- reveals: vec![built.reveal],
- signature: "11".repeat(64),
- }];
+ reveal_block.reveal_bundle_section = RevealBundleSection {
+ signatures: vec![RevealBundleSignature {
+ slot: 0,
+ member: reveal_block.miner.clone(),
+ signature: "11".repeat(64),
+ }],
+ reveals: vec![MaskedBlindedReveal {
+ reveal: built.reveal,
+ bundle_mask: 1,
+ }],
+ };
let chain = vec![commit_block.clone(), reveal_block.clone()];
let snapshot = fake_snapshot(BTreeMap::new(), chain.clone());
let revealed_by_height = super::revealed_transactions_by_height(&snapshot);
@@ -5170,7 +5178,7 @@ mod tests {
vdf_output: "vdf".to_string(),
leader_proof: None,
blinded_transactions: Vec::new(),
- reveal_bundles: Vec::new(),
+ reveal_bundle_section: RevealBundleSection::default(),
transactions,
hash: format!("hash-{height}"),
}
diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs
@@ -2754,7 +2754,7 @@ mod tests {
vdf_output: "vdf".to_string(),
leader_proof: None,
blinded_transactions: Vec::new(),
- reveal_bundles: Vec::new(),
+ reveal_bundle_section: crate::domain::RevealBundleSection::default(),
transactions: Vec::new(),
hash: "hash".to_string(),
};
diff --git a/src/domain.rs b/src/domain.rs
@@ -522,6 +522,97 @@ impl RevealBundle {
}
}
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RevealBundleSignature {
+ pub slot: u8,
+ pub member: String,
+ pub signature: String,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MaskedBlindedReveal {
+ pub reveal: BlindedReveal,
+ pub bundle_mask: u8,
+}
+
+#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RevealBundleSection {
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub signatures: Vec<RevealBundleSignature>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub reveals: Vec<MaskedBlindedReveal>,
+}
+
+impl RevealBundleSection {
+ pub fn is_empty(&self) -> bool {
+ self.signatures.is_empty() && self.reveals.is_empty()
+ }
+
+ pub fn all_reveals(&self) -> Vec<&BlindedReveal> {
+ self.reveals.iter().map(|masked| &masked.reveal).collect()
+ }
+
+ pub fn included_bundle_count(&self) -> usize {
+ self.signatures.len()
+ }
+
+ pub fn expand(&self, height: u64, prev_hash: &str) -> Vec<RevealBundle> {
+ self.signatures
+ .iter()
+ .map(|signature| {
+ let slot_mask = reveal_bundle_slot_mask(signature.slot).unwrap_or(0);
+ let reveals = self
+ .reveals
+ .iter()
+ .filter(|masked| masked.bundle_mask & slot_mask != 0)
+ .map(|masked| masked.reveal.clone())
+ .collect();
+ RevealBundle {
+ height,
+ prev_hash: prev_hash.to_string(),
+ slot: signature.slot,
+ member: signature.member.clone(),
+ reveals,
+ signature: signature.signature.clone(),
+ }
+ })
+ .collect()
+ }
+
+ pub fn reveal_bundle_hashes(
+ &self,
+ height: u64,
+ prev_hash: &str,
+ ) -> [String; REVEAL_COMMITTEE_SIZE] {
+ let bundles = self.expand(height, prev_hash);
+ reveal_bundle_hashes(&bundles)
+ }
+
+ fn canonical(&self) -> String {
+ let signatures = self
+ .signatures
+ .iter()
+ .map(|signature| {
+ format!(
+ "{}:{}:{}",
+ signature.slot, signature.member, signature.signature
+ )
+ })
+ .collect::<Vec<_>>()
+ .join("|");
+ let reveals = self
+ .reveals
+ .iter()
+ .map(|masked| format!("{}:{}", masked.bundle_mask, masked.reveal.canonical()))
+ .collect::<Vec<_>>()
+ .join("|");
+ format!("reveal-bundle-section-v1:{signatures}:reveals:{reveals}")
+ }
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
struct RevealBundlePayload {
height: u64,
@@ -556,8 +647,8 @@ pub struct RevealCommitteeMember {
pub amount: Amount,
}
-fn canonical_blinded_block_items(blinded: &str, reveals: &str, bundles: &str) -> String {
- format!("blinded-v2:{blinded}:reveals:{reveals}:bundles:{bundles}")
+fn canonical_blinded_block_items(blinded: &str, reveal_section: &str) -> String {
+ format!("blinded-v3:{blinded}:reveal-section:{reveal_section}")
}
impl TxInput {
@@ -907,7 +998,7 @@ pub struct Block {
#[serde(default)]
pub blinded_transactions: Vec<BlindedTransaction>,
#[serde(default)]
- pub reveal_bundles: Vec<RevealBundle>,
+ pub reveal_bundle_section: RevealBundleSection,
pub transactions: Vec<Transaction>,
pub hash: String,
}
@@ -934,7 +1025,7 @@ impl Block {
vdf_output: draft.vdf_output,
leader_proof: draft.leader_proof,
blinded_transactions: draft.blinded_transactions,
- reveal_bundles: draft.reveal_bundles,
+ reveal_bundle_section: draft.reveal_bundle_section,
transactions: draft.transactions,
hash: String::new(),
};
@@ -979,18 +1070,7 @@ impl Block {
.map(BlindedTransaction::canonical)
.collect::<Vec<_>>()
.join("|");
- let reveals = self
- .all_blinded_reveals()
- .iter()
- .map(|reveal| reveal.canonical())
- .collect::<Vec<_>>()
- .join("|");
- let bundles = self
- .reveal_bundles
- .iter()
- .map(RevealBundle::canonical)
- .collect::<Vec<_>>()
- .join("|");
+ let reveal_section = self.reveal_bundle_section.canonical();
let leader_proof = self
.leader_proof
.as_ref()
@@ -1001,7 +1081,7 @@ impl Block {
)
})
.unwrap_or_default();
- if !self.blinded_transactions.is_empty() || !self.reveal_bundles.is_empty() {
+ if !self.blinded_transactions.is_empty() || !self.reveal_bundle_section.is_empty() {
return hex_hash(format!(
"block-content-v3:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}",
self.height,
@@ -1013,7 +1093,7 @@ impl Block {
self.vdf_rounds,
leader_proof,
txs,
- canonical_blinded_block_items(&blinded, &reveals, &bundles)
+ canonical_blinded_block_items(&blinded, &reveal_section)
));
}
hex_hash(format!(
@@ -1080,20 +1160,16 @@ impl Block {
}
pub fn all_blinded_reveals(&self) -> Vec<&BlindedReveal> {
- let mut seen = BTreeSet::new();
- self.reveal_bundles
- .iter()
- .flat_map(|bundle| bundle.reveals.iter())
- .filter(|reveal| seen.insert(reveal.commitment.clone()))
- .collect()
+ self.reveal_bundle_section.all_reveals()
}
pub fn reveal_bundle_hashes(&self) -> [String; REVEAL_COMMITTEE_SIZE] {
- reveal_bundle_hashes(&self.reveal_bundles)
+ self.reveal_bundle_section
+ .reveal_bundle_hashes(self.height, &self.prev_hash)
}
pub fn included_reveal_bundle_count(&self) -> usize {
- self.reveal_bundles.len()
+ self.reveal_bundle_section.included_bundle_count()
}
}
@@ -1193,7 +1269,7 @@ pub struct PreparedBlock {
vdf_seed: String,
leader_ticket: Option<BurnTicket>,
blinded_transactions: Vec<BlindedTransaction>,
- reveal_bundles: Vec<RevealBundle>,
+ reveal_bundle_section: RevealBundleSection,
transactions: Vec<Transaction>,
}
@@ -1257,7 +1333,7 @@ impl PreparedBlock {
vdf_output,
leader_proof,
blinded_transactions: self.blinded_transactions,
- reveal_bundles: self.reveal_bundles,
+ reveal_bundle_section: self.reveal_bundle_section,
transactions: self.transactions,
})
}
@@ -1276,7 +1352,7 @@ struct BlockDraft {
vdf_output: String,
leader_proof: Option<LeaderProof>,
blinded_transactions: Vec<BlindedTransaction>,
- reveal_bundles: Vec<RevealBundle>,
+ reveal_bundle_section: RevealBundleSection,
transactions: Vec<Transaction>,
}
@@ -2027,6 +2103,130 @@ impl Ledger {
self.validate_reveal_bundles_for_block(expected_height, &expected_prev_hash, bundles)
}
+ fn reveal_bundle_section_from_bundles(
+ &self,
+ bundles: Vec<RevealBundle>,
+ ) -> RevealBundleSection {
+ let signatures = bundles
+ .iter()
+ .map(|bundle| RevealBundleSignature {
+ slot: bundle.slot,
+ member: bundle.member.clone(),
+ signature: bundle.signature.clone(),
+ })
+ .collect::<Vec<_>>();
+ let mut by_commitment: BTreeMap<String, MaskedBlindedReveal> = BTreeMap::new();
+ for bundle in bundles {
+ let slot_mask = reveal_bundle_slot_mask(bundle.slot).unwrap_or(0);
+ for reveal in bundle.reveals {
+ by_commitment
+ .entry(reveal.commitment.clone())
+ .and_modify(|masked| masked.bundle_mask |= slot_mask)
+ .or_insert(MaskedBlindedReveal {
+ reveal,
+ bundle_mask: slot_mask,
+ });
+ }
+ }
+ let mut reveals = by_commitment.into_values().collect::<Vec<_>>();
+ reveals.sort_by(|left, right| {
+ self.reveal_fee_order_key(&right.reveal)
+ .cmp(&self.reveal_fee_order_key(&left.reveal))
+ .then_with(|| left.reveal.commitment.cmp(&right.reveal.commitment))
+ });
+ RevealBundleSection {
+ signatures,
+ reveals,
+ }
+ }
+
+ fn validate_reveal_bundle_section_for_block(
+ &self,
+ expected_height: u64,
+ expected_prev_hash: &str,
+ section: &RevealBundleSection,
+ ) -> Result<()> {
+ if section.signatures.len() > REVEAL_COMMITTEE_SIZE {
+ bail!("block has too many reveal bundle signatures");
+ }
+ if section
+ .signatures
+ .windows(2)
+ .any(|pair| pair[0].slot >= pair[1].slot)
+ {
+ bail!("reveal bundle signatures are not in slot order");
+ }
+ let committee = self
+ .reveal_committee_for_height(expected_height)
+ .into_iter()
+ .map(|member| (member.slot, member))
+ .collect::<BTreeMap<_, _>>();
+ let mut seen_slots = BTreeSet::new();
+ let mut seen_members = BTreeSet::new();
+ let mut included_mask = 0_u8;
+ for signature in §ion.signatures {
+ if usize::from(signature.slot) >= REVEAL_COMMITTEE_SIZE {
+ bail!("reveal bundle slot is invalid");
+ }
+ if !seen_slots.insert(signature.slot) {
+ bail!("duplicate reveal bundle slot");
+ }
+ if !seen_members.insert(signature.member.clone()) {
+ bail!("duplicate reveal bundle member");
+ }
+ let member = committee
+ .get(&signature.slot)
+ .context("reveal bundle slot is not assigned")?;
+ if signature.member != member.owner {
+ bail!("reveal bundle member is not assigned to slot");
+ }
+ included_mask |= reveal_bundle_slot_mask(signature.slot)?;
+ }
+
+ let mut seen_reveals = BTreeSet::new();
+ let mut previous_key: Option<((u128, Amount), String)> = None;
+ for masked in §ion.reveals {
+ if masked.bundle_mask == 0 {
+ bail!("masked blinded reveal is not assigned to a reveal bundle");
+ }
+ if masked.bundle_mask & !reveal_committee_mask() != 0 {
+ bail!("masked blinded reveal references an invalid reveal bundle slot");
+ }
+ if masked.bundle_mask & !included_mask != 0 {
+ bail!("masked blinded reveal references a missing reveal bundle signature");
+ }
+ if !seen_reveals.insert(masked.reveal.commitment.clone()) {
+ bail!("duplicate blinded reveal in reveal bundle section");
+ }
+ self.pending_reveal_transaction(&masked.reveal)?;
+ let key = (
+ self.reveal_fee_order_key(&masked.reveal),
+ masked.reveal.commitment.clone(),
+ );
+ if let Some((previous_fee_key, previous_commitment)) = &previous_key {
+ if key.0 > *previous_fee_key
+ || key.0 == *previous_fee_key && key.1 < *previous_commitment
+ {
+ bail!("reveal bundle section is not fee ordered");
+ }
+ }
+ previous_key = Some(key);
+ }
+
+ for bundle in section.expand(expected_height, expected_prev_hash) {
+ if bundle.serialized_size_bytes()? > MAX_REVEAL_BUNDLE_BYTES {
+ bail!("reveal bundle exceeds max size");
+ }
+ verify_address_signature(
+ &bundle.member,
+ &bundle.canonical_payload(),
+ &bundle.signature,
+ "reveal bundle",
+ )?;
+ }
+ Ok(())
+ }
+
fn validate_reveal_bundles_for_block(
&self,
expected_height: u64,
@@ -2605,13 +2805,14 @@ impl Ledger {
}
let reveal_bundles = self.validate_next_block_reveal_bundles(reveal_bundles)?;
+ let reveal_bundle_section = self.reveal_bundle_section_from_bundles(reveal_bundles);
let selection = self.select_block_transactions()?;
ensure_block_has_burn(&selection.transactions)?;
let tip = self.tip();
let prev_hash = tip.hash.clone();
let timestamp_ms = timestamp_ms.max(ticket_block_min_timestamp(tip, finalizer_rank)?);
- let bundle_hashes = reveal_bundle_hashes(&reveal_bundles);
+ let bundle_hashes = reveal_bundle_section.reveal_bundle_hashes(height, &prev_hash);
let vdf_seed = vdf_seed_for_child(&prev_hash, height, &bundle_hashes);
Ok(PreparedBlock {
height,
@@ -2625,7 +2826,7 @@ impl Ledger {
finalizer_rank,
leader_ticket: Some(leader_ticket),
blinded_transactions: selection.blinded_transactions,
- reveal_bundles,
+ reveal_bundle_section,
transactions: selection.transactions,
})
}
@@ -2657,6 +2858,7 @@ impl Ledger {
}
let reveal_bundles = self.validate_next_block_reveal_bundles(reveal_bundles)?;
+ let reveal_bundle_section = self.reveal_bundle_section_from_bundles(reveal_bundles);
let selection = self.select_recovery_block_transactions(miner)?;
ensure_block_has_burn(&selection.transactions)?;
ensure_block_has_burn_from(&selection.transactions, miner)?;
@@ -2664,7 +2866,7 @@ impl Ledger {
let tip = self.tip();
let prev_hash = tip.hash.clone();
let timestamp_ms = timestamp_ms.max(tip.timestamp_ms + 1);
- let bundle_hashes = reveal_bundle_hashes(&reveal_bundles);
+ let bundle_hashes = reveal_bundle_section.reveal_bundle_hashes(height, &prev_hash);
let vdf_seed =
recovery_vdf_seed_for_child(&prev_hash, height, timestamp_ms, &bundle_hashes);
Ok(PreparedBlock {
@@ -2679,7 +2881,7 @@ impl Ledger {
vdf_seed,
leader_ticket: None,
blinded_transactions: selection.blinded_transactions,
- reveal_bundles,
+ reveal_bundle_section,
transactions: selection.transactions,
})
}
@@ -2914,10 +3116,10 @@ impl Ledger {
bail!("block exceeds max block size");
}
ensure_block_has_burn(&block.transactions)?;
- self.validate_reveal_bundles_for_block(
+ self.validate_reveal_bundle_section_for_block(
block.height,
&block.prev_hash,
- block.reveal_bundles.clone(),
+ &block.reveal_bundle_section,
)?;
validate_block_blinded_items(block, self)?;
match block.finalizer_mode {
@@ -4117,7 +4319,7 @@ fn estimated_block_selection_size_bytes(
signature: "f".repeat(128),
}),
blinded_transactions: selection.blinded_transactions.clone(),
- reveal_bundles: Vec::new(),
+ reveal_bundle_section: RevealBundleSection::default(),
transactions: selection.transactions.clone(),
hash: "f".repeat(64),
};
@@ -4188,6 +4390,17 @@ pub fn default_reveal_bundle_hash(slot: usize) -> String {
hex_hash(format!("iuna-default-reveal-bundle-v1:{slot}"))
}
+fn reveal_bundle_slot_mask(slot: u8) -> Result<u8> {
+ if usize::from(slot) >= REVEAL_COMMITTEE_SIZE || slot >= 8 {
+ bail!("reveal bundle slot is invalid");
+ }
+ Ok(1_u8 << slot)
+}
+
+fn reveal_committee_mask() -> u8 {
+ (0..REVEAL_COMMITTEE_SIZE).fold(0_u8, |mask, slot| mask | (1_u8 << slot))
+}
+
fn reveal_bundle_hashes(bundles: &[RevealBundle]) -> [String; REVEAL_COMMITTEE_SIZE] {
std::array::from_fn(|slot| {
bundles
@@ -4553,7 +4766,7 @@ fn build_genesis_block(
vdf_output,
leader_proof: None,
blinded_transactions: Vec::new(),
- reveal_bundles: Vec::new(),
+ reveal_bundle_section: RevealBundleSection::default(),
transactions,
hash: String::new(),
};
@@ -4658,7 +4871,7 @@ fn validate_genesis_block(block: &Block) -> Result<()> {
if block.leader_proof.is_some() {
bail!("genesis block must not carry a leader proof");
}
- if !block.blinded_transactions.is_empty() || !block.reveal_bundles.is_empty() {
+ if !block.blinded_transactions.is_empty() || !block.reveal_bundle_section.is_empty() {
bail!("genesis block must not carry blinded transactions");
}
if block.compute_hash() != block.hash {
@@ -5051,7 +5264,7 @@ mod tests {
vdf_output: String::new(),
leader_proof: None,
blinded_transactions: Vec::new(),
- reveal_bundles: Vec::new(),
+ reveal_bundle_section: RevealBundleSection::default(),
transactions: Vec::new(),
hash: String::new(),
}
@@ -6053,7 +6266,7 @@ mod tests {
signature: "signature".to_string(),
}),
blinded_transactions: Vec::new(),
- reveal_bundles: Vec::new(),
+ reveal_bundle_section: RevealBundleSection::default(),
transactions: Vec::new(),
});
@@ -6290,6 +6503,69 @@ mod tests {
}
#[test]
+ fn reveal_bundle_section_deduplicates_reveals_with_slot_mask() {
+ let alice = Wallet::from_seed("bundle-compact-alice");
+ let bob = Wallet::from_seed("bundle-compact-bob");
+ let carol = Wallet::from_seed("bundle-compact-carol");
+ let dave = Wallet::from_seed("bundle-compact-dave");
+ let finalizers = [alice.clone(), bob.clone(), carol.clone()];
+ let mut ledger = ledger_with_finalizers(&finalizers, &[(&dave, 10 * MICRO_IUNA)]);
+ let blinded = ledger
+ .build_blinded_burn(&dave, 3, 7, ledger.height() + 4)
+ .unwrap();
+ ledger
+ .submit_blinded_transaction(blinded.transaction.clone())
+ .unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
+ ledger
+ .submit_blinded_reveal(blinded.reveal.clone())
+ .unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
+
+ let mut bundles = ledger
+ .reveal_committee_for_next_block()
+ .into_iter()
+ .filter_map(|member| {
+ let wallet = wallet_for_address(&finalizers, &member.owner);
+ ledger.build_reveal_bundle(wallet).unwrap()
+ })
+ .collect::<Vec<_>>();
+ assert!(bundles.len() >= 2);
+ bundles.truncate(2);
+ let expected_hashes = reveal_bundle_hashes(&bundles);
+ let expected_mask = bundles
+ .iter()
+ .fold(0_u8, |mask, bundle| mask | (1_u8 << bundle.slot));
+
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let leader_wallet = wallet_for_address(&finalizers, &leader);
+ let prepared = ledger
+ .prepare_next_block_with_reveal_bundles(
+ leader_wallet.address(),
+ ledger.tip().timestamp_ms + 1,
+ bundles.clone(),
+ )
+ .unwrap();
+ let block = prepared.finish(leader_wallet, "preverified-vdf".to_string());
+
+ assert_eq!(block.reveal_bundle_section.signatures.len(), 2);
+ assert_eq!(block.reveal_bundle_section.reveals.len(), 1);
+ assert_eq!(
+ block.reveal_bundle_section.reveals[0].bundle_mask,
+ expected_mask
+ );
+ assert_eq!(block.all_blinded_reveals(), vec![&blinded.reveal]);
+ assert_eq!(block.reveal_bundle_hashes(), expected_hashes);
+ assert_eq!(
+ block
+ .reveal_bundle_section
+ .expand(block.height, &block.prev_hash),
+ bundles
+ );
+ }
+
+ #[test]
fn reveal_bundle_validation_rejects_wrong_signature_and_slot() {
let alice = Wallet::from_seed("bundle-invalid-alice");
let bob = Wallet::from_seed("bundle-invalid-bob");
@@ -6356,15 +6632,15 @@ mod tests {
commitment: blinded.transaction.commitment,
key: "00".repeat(BLINDED_KEY_BYTES),
};
- prepared
- .reveal_bundles
- .push(committee_wallet.reveal_bundle(RevealBundlePayload {
- height: prepared.height,
- prev_hash: prepared.prev_hash.clone(),
- slot: committee_member.slot,
- member: committee_wallet.address().to_string(),
- reveals: vec![wrong_reveal],
- }));
+ let wrong_bundle = committee_wallet.reveal_bundle(RevealBundlePayload {
+ height: prepared.height,
+ prev_hash: prepared.prev_hash.clone(),
+ slot: committee_member.slot,
+ member: committee_wallet.address().to_string(),
+ reveals: vec![wrong_reveal],
+ });
+ prepared.reveal_bundle_section =
+ ledger.reveal_bundle_section_from_bundles(vec![wrong_bundle]);
let block = prepared.finish(wallet, "preverified-vdf".to_string());
let error = ledger