commit b986866cce1ac2705d373d163a32ac75c4b78544
parent 1d241f874cb2a0db490d4619a9312de493ab1a55
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Mon, 17 Aug 2026 23:01:55 +0200
Harden reveal committee lineage selection
Diffstat:
17 files changed, 1302 insertions(+), 105 deletions(-)
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -52,7 +52,7 @@ Every normal block must include at least one plaintext burn. A blinded transacti
This mandatory anchor 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.
-Wallet-created transfers and burns are not gossiped as plaintext. Their blinded envelopes expose and lock UTXO inputs before reveal, so declared fees are backed by spendable coins. Mine actions are public mempool items, because they do not reveal burn or transfer intent and must be possible without owning coins. The local plaintext anchor burn for finalization is separate from the configured automatic blinded burn per block. When automatic burning is enabled, the configured burn amount enters the network as a blinded envelope like other wallet-created burns. When a blinded payload is revealed and executed, `35%` of its fee 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 local plaintext anchor burn required for block liveness is part of the block reward like other plaintext block items.
+Wallet-created transfers and burns are not gossiped as plaintext. Their blinded envelopes expose and lock UTXO inputs before reveal, so declared fees are backed by spendable coins. Mine actions are public mempool items, because they do not reveal burn or transfer intent and must be possible without owning coins. The local plaintext anchor burn for finalization is separate from the configured automatic blinded burn per block. When automatic burning is enabled, the configured burn amount enters the network as a blinded envelope like other wallet-created burns. When a blinded payload is revealed and executed, `35%` of its fee goes to the finalizer that originally committed the envelope, up to `35%` goes to the reveal-block finalizer, and `10%` goes to each included explicit signed reveal-list maker. Missing reveal-list shares, missing reveal-finalizer shares, and rounding dust are burned. The local plaintext anchor burn required for block liveness is part of the block reward like other plaintext block items.
## VDF Timing
@@ -140,7 +140,27 @@ The visible inputs are signed for the blinded envelope itself and are not repeat
Reveal is a later step. A `BlindedReveal` carries only the commitment and decryption key. Reveals are not included as loose block items. They are carried in signed reveal bundles.
-For each next block height, nodes compute a reveal committee from the burn leader ranking. Slot `0` is assigned to the rank `0` block finalizer, so the selected finalizer can always sign a reveal list for its own block. Before height `500`, the remaining slots are assigned to the two lowest-ranked eligible tickets. Starting at height `500`, the remaining slots are assigned to the next highest-ranked eligible tickets with owners that are not already in the committee, up to three unique owners total. 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. Starting at height `1500`, once a blinded envelope is active, committee members also gossip empty bundles when they know no valid reveal for the next height; the empty signature is an attestation that keeps the reveal layer explicit without forcing a reveal to exist.
+Before height `1500`, nodes compute a reveal committee from the burn leader ranking. Slot `0` is assigned to the rank `0` block finalizer, so the selected finalizer can sign a reveal list for its own block. Before height `500`, the remaining slots are assigned to the two lowest-ranked eligible tickets. Starting at height `500`, the remaining slots are assigned to the next highest-ranked eligible tickets with owners that are not already in the committee, up to three unique owners total.
+
+Starting at height `1500`, each block has one burn-selected finalizer and up to two additional independent reveal committee members. The finalizer remains reveal committee slot `0`, but does not need to include a separate reveal bundle signature: the finalizer already signs the block header, and that block signature commits to the selected reveal-bundle section. The additional reveal committee slots are selected from mature UTXO lineages, not from burn tickets, so block production remains proportional to burn while reveal witnessing is Sybil-resistant.
+
+A UTXO lineage root is the newest mine action output in an output's ancestry. Transfer and change outputs carry that single root tag forward, even before the root is mature, so early transfers do not lose their ancestry. When outputs from multiple roots are merged and spent, descendants inherit the newest root among the spent inputs; ties are broken deterministically by root outpoint. If none of the spent inputs has a mine root, the new output has no reveal-committee lineage weight. A lineage root is eligible for reveal committee selection only when its mine action is at least `20` blocks old at the parent tip.
+
+Nodes cache this root tag on every UTXO and maintain an incremental index from root to total unspent value. Normal block validation updates that cache only for the inputs spent and outputs created by the block. A broad ancestry search is only a rebuild or migration tool, not part of the consensus hot path.
+
+Committee selection is root-first. For each eligible lineage root, validators sum the unspent value currently tagged with that root:
+
+`root_value = sum(unspent_value_micro_iuna_tagged_with_root)`
+
+The root's committee weight is:
+
+`root_weight = floor(log2(1 + root_value))`
+
+Splitting one large root across many addresses does not multiply committee influence, because the root is weighted once and can win at most one additional reveal slot. Merging roots deliberately collapses future descendant lineage to the newest root; lineage is a Sybil-resistance tag, not full coin-provenance accounting.
+
+For a target height, validators derive a deterministic committee seed from the parent hash and height. Slot `0` is assigned to the block finalizer. Slots `1` and `2` are assigned without replacement by weighted deterministic draws over eligible lineage roots using `root_weight`. Lineage roots currently owned by the block finalizer are excluded from these additional draws, and after any other root wins a slot, that root is removed from the next slot draw. After a root is selected, validators choose one representative owner for that root from the unspent outputs tagged with it. The block finalizer's address and any address already selected for an earlier additional reveal slot are also skipped for additional slots. If fewer than two eligible non-finalizer lineages exist, the committee has the finalizer plus one or zero additional members. If no eligible non-finalizer lineage exists, reveal quorum falls back to `1-of-1` through the finalizer's implicit slot `0` attestation.
+
+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. Starting at height `1500`, once a blinded envelope is active, committee members also gossip empty bundles when they know no valid reveal for the next height; the empty signature is an attestation that keeps the reveal layer explicit without forcing a reveal to exist.
Automatic nodes wait about `30 seconds` after seeing pending reveals for the next height before signing a reveal bundle or starting the reveal-bound VDF. Starting at height `1500`, active blinded envelopes also trigger this wait so empty attestations can be collected. This gives reveal gossip time to settle and avoids locking in an underfilled bundle from the first partial batch a node received.
@@ -148,37 +168,37 @@ A block has an envelope section and one compact reveal-bundle section. The envel
The compact reveal-bundle section stores:
-- up to three bundle signatures, one per committee slot, in slot order;
+- up to two explicit reveal committee bundle signatures for non-finalizer slots, 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.
+Validators reconstruct each signed committee bundle from this compact section before checking signatures, bundle size, slot assignment, lineage-based slot assignment, and fee ordering. The finalizer's block signature is treated as its committee attestation for slot `0`. Slot `0` does not have a separate reveal bundle payload or reveal-list-maker fee after height `1500`; it attests to the block's deduplicated reveal list as included by the finalizer. This keeps consensus bound to the independent reveal attestations without storing the same reveal payload multiple times when multiple 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.
Before height `1500`, reveal-list signatures are optional for chain compatibility. Starting at height `1500`, if there are no active blinded envelopes before a block, no reveal-list threshold is required. If active blinded envelopes exist, ticket blocks must carry enough reveal-list signatures for their finalizer rank:
-- rank `0` needs all available reveal committee signatures (`3-of-3`, `2-of-2`, or `1-of-1`);
-- rank `1` needs two signatures when possible (`2-of-3`, `2-of-2`, or `1-of-1`);
-- rank `2` and later ticket finalizers need one signature.
+- rank `0` needs all available reveal committee attestations (`3-of-3`, `2-of-2`, or `1-of-1`), where the finalizer's block signature counts as the slot `0` attestation;
+- rank `1` needs two reveal committee attestations when possible (`2-of-3`, `2-of-2`, or `1-of-1`), where the finalizer's block signature counts as the slot `0` attestation;
+- rank `2` and later ticket finalizers may publish without reveal committee signatures, but must include any valid signatures already bound into their VDF seed.
-Recovery blocks do not require reveal-list signatures; their job is chain liveness after the ticket path has failed. A valid signed bundle may be empty. Honest committee policy is to sign an empty bundle only when the signer knows no valid reveal for that height, and to include every valid reveal it selects by the canonical fee ordering. The consensus rule checks committee membership, signature validity, ordering, and threshold; it does not depend on a validator's local mempool contents.
+Recovery blocks do not require reveal-list signatures; their job is chain liveness after the ticket path has failed. A valid signed bundle may be empty. Honest committee policy is to sign an empty bundle only when the signer knows no valid reveal for that height, and to include every valid reveal it selects by the canonical fee ordering. The consensus rule checks committee membership, signature validity, lineage assignment, ordering, and threshold; it does not depend on a validator's local mempool contents.
-The ticket-block VDF seed is bound to the reveal bundle hashes:
+The ticket-block VDF seed is bound to the reveal attestation hashes:
-`seed = hash(parent hash || height || bundle_hash[0] || bundle_hash[1] || bundle_hash[2])`
+`seed = hash(parent hash || height || attestation_hash[0] || attestation_hash[1] || attestation_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])`
+`seed = hash(parent hash || height || timestamp_ms || attestation_hash[0] || attestation_hash[1] || attestation_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.
+Before height `1500`, each attestation hash is the signed reveal-bundle hash for that slot, or a fixed default hash when the slot has no included bundle. Starting at height `1500`, slot `0` uses a synthetic finalizer attestation hash derived from the parent, height, finalizer address, and the block's canonical deduplicated reveal list. Slot `0` therefore attests to every reveal executed by the block, independent of which explicit bundles also contained it. Slots `1` and `2` use the signed reveal-bundle hash or the fixed default hash when absent. This means the finalizer must choose the reveal-attestation 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.
+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 included reveal attestations divided by the available committee slots for that height. Before height `1500`, signed reveal bundles define those attestations. Starting at height `1500`, the denominator is the total available committee size including implicit slot `0` (`3`, `2`, or `1`). The finalizer's implicit slot `0` attestation counts for the scaled reveal-finalizer share for every reveal in the block, so the reveal-block finalizer always receives at least one slot's share when it includes a valid reveal. Slot `0` does not earn the separate reveal-list-maker share. `10%` goes to each included explicit signed reveal-list maker for non-finalizer slots. 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.
+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 attestations for that reveal, not merely the number of bundle signatures included somewhere in the block. Before height `1500`, those attestations are signed bundles. Starting at height `1500`, slot `0` is counted as attesting to every reveal in the block through the finalizer's block signature, while slots `1` and `2` count only when their signed bundle contained the reveal. 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.
@@ -210,7 +230,7 @@ When a node builds a block, it selects transactions in this order:
2. Reserve the local plaintext anchor burn as the first plaintext block item.
3. For recovery blocks, ensure at least one plaintext anchor burn is from the recovery finalizer.
4. Fill remaining envelope space with valid fee-paying public mine actions and blinded transaction envelopes ordered by fee rate. Public mine actions are limited to `2` actions per anchor.
-5. Bind the VDF seed to the three reveal-bundle slot hashes, using default hashes for missing slots.
+5. Bind the VDF seed to the three reveal-attestation slot hashes, using default hashes for missing slots. Starting at height `1500`, slot `0` uses the synthetic finalizer attestation hash instead of a separate reveal-bundle hash.
Blocks are bounded by transaction count and serialized byte size. The devnet maximum block size is `100,000` bytes.
diff --git a/src/domain.rs b/src/domain.rs
@@ -12,6 +12,7 @@ mod ledger_apply;
mod ledger_builders;
mod ledger_chain;
mod ledger_consensus;
+mod ledger_lineage;
mod ledger_mempool;
mod ledger_ops;
mod ledger_pending;
@@ -52,6 +53,13 @@ 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_lineage::newest_lineage_root;
+use ledger_lineage::{
+ LineageOwnerValues, UtxoLineageRoot, insert_output_with_lineage,
+ output_lineage_root_for_transaction, spend_blinded_inputs_with_lineage,
+ spend_inputs_with_lineage,
+};
+#[cfg(test)]
use ledger_ops::estimated_block_selection_size_bytes;
#[cfg(test)]
use ledger_ops::fee_reward;
@@ -83,7 +91,7 @@ pub use protocol::{
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_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, REVEAL_COMMITTEE_SIZE,
- REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, TransactionSubmitOutcome,
+ REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, REVEAL_LINEAGE_MATURITY_HEIGHTS, TransactionSubmitOutcome,
UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT, VDF_TARGET_BLOCK_MS,
};
use protocol::{
diff --git a/src/domain/blinded.rs b/src/domain/blinded.rs
@@ -14,7 +14,7 @@ use super::{
Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, BLINDED_KEY_BYTES,
BLINDED_NONCE_BYTES, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, BlindedTransaction,
OutPoint, PUBLIC_KEY_BYTES, RevealBundleSignature, SIGNATURE_BYTES, Transaction, TxInput,
- TxOutput, blinded_reveal_finalizer_fee, decode_hex, decode_hex_array,
+ TxOutput, UtxoLineageRoot, blinded_reveal_finalizer_fee, decode_hex, decode_hex_array,
ensure_outputs_do_not_overflow, ensure_single_input_owner_for_inputs, hex_hash,
};
@@ -22,6 +22,7 @@ use super::{
pub(super) struct ActiveBlindedTransaction {
pub(super) transaction: BlindedTransaction,
pub(super) locked_outputs: Vec<TxOutput>,
+ pub(super) locked_lineage_root: Option<UtxoLineageRoot>,
pub(super) included_height: u64,
pub(super) included_by: String,
}
diff --git a/src/domain/block.rs b/src/domain/block.rs
@@ -127,7 +127,7 @@ impl Block {
pub fn reveal_bundle_hashes(&self) -> [String; REVEAL_COMMITTEE_SIZE] {
self.reveal_bundle_section
- .reveal_bundle_hashes(self.height, &self.prev_hash)
+ .reveal_bundle_hashes(self.height, &self.prev_hash, &self.miner)
}
pub fn included_reveal_bundle_count(&self) -> usize {
diff --git a/src/domain/history.rs b/src/domain/history.rs
@@ -49,6 +49,7 @@ pub fn revealed_blinded_transactions(
ActiveBlindedTransaction {
transaction: transaction.clone(),
locked_outputs: Vec::new(),
+ locked_lineage_root: None,
included_height: block.height,
included_by: block.miner.clone(),
},
diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs
@@ -3,11 +3,12 @@ use std::collections::BTreeSet;
use anyhow::{Context, Result, bail};
use super::blinded::{
- ActiveBlindedTransaction, credit_blinded_fee_outputs, credit_expired_blinded_outputs,
+ ActiveBlindedTransaction, blinded_expiry_change_outpoint, blinded_locked_output_total,
+ credit_blinded_fee_outputs, credit_expired_blinded_outputs,
};
use super::ledger_ops::{
- apply_transaction, block_reward, credit_reward_output, ensure_block_has_burn,
- ensure_valid_recovery_block, spend_blinded_inputs, validate_block_blinded_items,
+ block_reward, credit_reward_output, ensure_block_has_burn, ensure_outputs_do_not_overflow,
+ ensure_single_input_owner, ensure_valid_recovery_block, validate_block_blinded_items,
validate_block_fee_policy, verify_leader_proof,
};
use super::mine_policy::ensure_mine_anchor_limit;
@@ -20,7 +21,8 @@ use super::{
Amount, BLOCK_ITEM_FEES_REQUIRED_HEIGHT, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block, FinalizerMode,
Ledger, MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, MaskedBlindedReveal,
REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, RevealBundleSection, RevealBundleSignature, Transaction,
- blinded_reveal_finalizer_fee, unix_now_ms, verify_vdf,
+ blinded_reveal_finalizer_fee, insert_output_with_lineage, output_lineage_root_for_transaction,
+ spend_blinded_inputs_with_lineage, spend_inputs_with_lineage, unix_now_ms, verify_vdf,
};
impl Ledger {
@@ -76,6 +78,9 @@ impl Ledger {
let reveal_bundle_slot_count = self.reveal_committee_for_height(block.height).len();
let mut utxos = self.utxos.clone();
+ let mut utxo_lineage = self.utxo_lineage.clone();
+ let mut lineage_values = self.lineage_values.clone();
+ let mut lineage_owners = self.lineage_owners.clone();
let mut signatures = BTreeSet::new();
let mut revealed_transactions = Vec::new();
let mut aggregated_reveal_finalizer_fees = 0_u64;
@@ -84,7 +89,14 @@ impl Ledger {
bail!("duplicate transaction in block");
}
self.validate_transaction_terms(tx)?;
- apply_transaction(tx, &mut utxos)?;
+ apply_transaction_with_lineage(
+ tx,
+ block.height,
+ &mut utxos,
+ &mut utxo_lineage,
+ &mut lineage_values,
+ &mut lineage_owners,
+ )?;
}
let mut revealed_commitments = BTreeSet::new();
for masked in &block.reveal_bundle_section.reveals {
@@ -104,7 +116,14 @@ impl Ledger {
BLOCK_ITEM_FEES_REQUIRED_HEIGHT
);
}
- self.apply_revealed_blinded_transaction(&active, &tx, &mut utxos)?;
+ apply_revealed_blinded_transaction_with_lineage(
+ &active,
+ &tx,
+ &mut utxos,
+ &mut utxo_lineage,
+ &mut lineage_values,
+ &mut lineage_owners,
+ )?;
let reveal_bundle_signatures = reveal_fee_signatures_for_height(
block.height,
&block.reveal_bundle_section,
@@ -137,6 +156,23 @@ impl Ledger {
&& block.height >= active.transaction.expires_at_height
{
credit_expired_blinded_outputs(&mut utxos, active)?;
+ if let Some((outpoint, output)) = utxos
+ .get_key_value(&blinded_expiry_change_outpoint(commitment))
+ .map(|(outpoint, output)| (outpoint.clone(), output.clone()))
+ {
+ if let Some(root) = active.locked_lineage_root.clone() {
+ utxos.remove(&outpoint);
+ insert_output_with_lineage(
+ outpoint,
+ output,
+ Some(root),
+ &mut utxos,
+ &mut utxo_lineage,
+ &mut lineage_values,
+ &mut lineage_owners,
+ )?;
+ }
+ }
}
}
let expected_reward = block_reward(&block.transactions, aggregated_reveal_finalizer_fees)?;
@@ -160,12 +196,19 @@ impl Ledger {
.collect::<BTreeSet<_>>();
let mut new_active_blinded = Vec::new();
for transaction in &block.blinded_transactions {
- let locked_outputs = spend_blinded_inputs(transaction, &mut utxos)?;
+ let (locked_outputs, locked_lineage_root) = spend_blinded_inputs_with_lineage(
+ transaction,
+ &mut utxos,
+ &mut utxo_lineage,
+ &mut lineage_values,
+ &mut lineage_owners,
+ )?;
new_active_blinded.push((
transaction.commitment.clone(),
ActiveBlindedTransaction {
transaction: transaction.clone(),
locked_outputs,
+ locked_lineage_root,
included_height: block.height,
included_by: block.miner.clone(),
},
@@ -181,6 +224,9 @@ impl Ledger {
)?);
credit_reward_output(&mut utxos, &block)?;
self.utxos = utxos;
+ self.utxo_lineage = utxo_lineage;
+ self.lineage_values = lineage_values;
+ self.lineage_owners = lineage_owners;
self.tickets = tickets;
self.chain.push(block);
let new_height = self.height();
@@ -410,7 +456,12 @@ pub(super) fn reveal_fee_bundle_count_for_height(
section: &RevealBundleSection,
masked: &MaskedBlindedReveal,
) -> usize {
- reveal_fee_signatures_for_height(height, section, masked).len()
+ let explicit = reveal_fee_signatures_for_height(height, section, masked).len();
+ if height >= super::REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT {
+ explicit.saturating_add(1)
+ } else {
+ explicit
+ }
}
pub(super) fn reveal_fee_signatures_for_height(
@@ -431,3 +482,122 @@ pub(super) fn reveal_fee_signatures_for_height(
.cloned()
.collect()
}
+
+fn apply_transaction_with_lineage(
+ transaction: &Transaction,
+ block_height: u64,
+ utxos: &mut std::collections::BTreeMap<super::OutPoint, super::TxOutput>,
+ utxo_lineage: &mut std::collections::BTreeMap<super::OutPoint, super::UtxoLineageRoot>,
+ lineage_values: &mut std::collections::BTreeMap<super::UtxoLineageRoot, Amount>,
+ lineage_owners: &mut super::LineageOwnerValues,
+) -> Result<()> {
+ transaction.verify_signature()?;
+ if matches!(transaction, Transaction::Mine { .. }) {
+ let output = transaction.outputs().remove(0);
+ ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?;
+ insert_output_with_lineage(
+ super::OutPoint {
+ txid: transaction.signature().to_string(),
+ index: 0,
+ },
+ output,
+ output_lineage_root_for_transaction(transaction, block_height, None),
+ utxos,
+ utxo_lineage,
+ lineage_values,
+ lineage_owners,
+ )?;
+ return Ok(());
+ }
+
+ ensure_single_input_owner(transaction)?;
+ let (input_total, inherited_root) = spend_inputs_with_lineage(
+ transaction,
+ utxos,
+ utxo_lineage,
+ lineage_values,
+ lineage_owners,
+ )?;
+ let outputs = transaction.outputs();
+ let output_total = outputs.iter().try_fold(0_u64, |total, output| {
+ total
+ .checked_add(output.amount)
+ .context("transaction outputs overflow")
+ })?;
+ let required = output_total
+ .checked_add(transaction.fee())
+ .context("transaction outputs plus fee overflow")?
+ .checked_add(match transaction {
+ Transaction::Burn { amount, .. } => *amount,
+ Transaction::Transfer { .. } | Transaction::Mine { .. } => 0,
+ })
+ .context("transaction outputs plus burn overflow")?;
+ if input_total != required {
+ bail!("transaction inputs do not balance outputs, burn, and fee");
+ }
+ ensure_outputs_do_not_overflow(utxos, &outputs)?;
+ for (index, output) in outputs.into_iter().enumerate() {
+ insert_output_with_lineage(
+ super::OutPoint {
+ txid: transaction.signature().to_string(),
+ index: index as u32,
+ },
+ output,
+ inherited_root.clone(),
+ utxos,
+ utxo_lineage,
+ lineage_values,
+ lineage_owners,
+ )?;
+ }
+ Ok(())
+}
+
+fn apply_revealed_blinded_transaction_with_lineage(
+ active: &ActiveBlindedTransaction,
+ transaction: &Transaction,
+ utxos: &mut std::collections::BTreeMap<super::OutPoint, super::TxOutput>,
+ utxo_lineage: &mut std::collections::BTreeMap<super::OutPoint, super::UtxoLineageRoot>,
+ lineage_values: &mut std::collections::BTreeMap<super::UtxoLineageRoot, Amount>,
+ lineage_owners: &mut super::LineageOwnerValues,
+) -> Result<()> {
+ if matches!(transaction, Transaction::Mine { .. }) {
+ bail!("mine actions are public and cannot be blinded");
+ }
+ transaction.verify_signature()?;
+ ensure_single_input_owner(transaction)?;
+ let input_total = blinded_locked_output_total(active)?;
+ let outputs = transaction.outputs();
+ let output_total = outputs.iter().try_fold(0_u64, |total, output| {
+ total
+ .checked_add(output.amount)
+ .context("transaction outputs overflow")
+ })?;
+ let required = output_total
+ .checked_add(transaction.fee())
+ .context("transaction outputs plus fee overflow")?
+ .checked_add(match transaction {
+ Transaction::Burn { amount, .. } => *amount,
+ Transaction::Transfer { .. } | Transaction::Mine { .. } => 0,
+ })
+ .context("transaction outputs plus burn overflow")?;
+ if input_total != required {
+ bail!("blinded transaction inputs do not balance outputs, burn, and fee");
+ }
+ ensure_outputs_do_not_overflow(utxos, &outputs)?;
+ for (index, output) in outputs.into_iter().enumerate() {
+ insert_output_with_lineage(
+ super::OutPoint {
+ txid: transaction.signature().to_string(),
+ index: index as u32,
+ },
+ output,
+ active.locked_lineage_root.clone(),
+ utxos,
+ utxo_lineage,
+ lineage_values,
+ lineage_owners,
+ )?;
+ }
+ Ok(())
+}
diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs
@@ -49,6 +49,9 @@ impl Ledger {
chain: vec![genesis],
genesis_allocations: genesis_allocations.clone(),
utxos,
+ utxo_lineage: BTreeMap::new(),
+ lineage_values: BTreeMap::new(),
+ lineage_owners: BTreeMap::new(),
tickets,
pending: Vec::new(),
orphans: Vec::new(),
@@ -78,7 +81,7 @@ impl Ledger {
Self::from_snapshot_with_vdf_policy(snapshot, true, now_ms)
}
- fn from_snapshot_with_vdf_policy(
+ pub(crate) fn from_snapshot_with_vdf_policy(
snapshot: ChainSnapshot,
verify_vdf: bool,
now_ms: u64,
@@ -108,6 +111,9 @@ impl Ledger {
chain: vec![genesis],
genesis_allocations,
utxos,
+ utxo_lineage: BTreeMap::new(),
+ lineage_values: BTreeMap::new(),
+ lineage_owners: BTreeMap::new(),
tickets: Vec::new(),
pending: Vec::new(),
orphans: Vec::new(),
@@ -151,7 +157,7 @@ impl Ledger {
self.extend_from_snapshot_with_vdf_policy(snapshot, true, now_ms)
}
- fn extend_from_snapshot_with_vdf_policy(
+ pub(crate) fn extend_from_snapshot_with_vdf_policy(
&mut self,
snapshot: ChainSnapshot,
verify_vdf: bool,
diff --git a/src/domain/ledger_lineage.rs b/src/domain/ledger_lineage.rs
@@ -0,0 +1,190 @@
+use std::collections::BTreeMap;
+
+use anyhow::{Context, Result, bail};
+
+use super::{Amount, OutPoint, Transaction, TxOutput};
+
+#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub(super) struct UtxoLineageRoot {
+ pub(super) outpoint: OutPoint,
+ pub(super) height: u64,
+}
+
+pub(super) type LineageOwnerValues =
+ BTreeMap<UtxoLineageRoot, BTreeMap<String, BTreeMap<OutPoint, Amount>>>;
+
+pub(super) fn spend_inputs_with_lineage(
+ transaction: &Transaction,
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+ utxo_lineage: &mut BTreeMap<OutPoint, UtxoLineageRoot>,
+ lineage_values: &mut BTreeMap<UtxoLineageRoot, Amount>,
+ lineage_owners: &mut LineageOwnerValues,
+) -> Result<(Amount, Option<UtxoLineageRoot>)> {
+ let mut seen = std::collections::BTreeSet::new();
+ let mut total = 0_u64;
+ let mut inherited_root = None;
+ for input in transaction.inputs() {
+ if !seen.insert(input.outpoint.clone()) {
+ bail!("duplicate input in transaction");
+ }
+ let output = utxos.remove(&input.outpoint).with_context(|| {
+ format!("transaction spends missing output {}", input.outpoint.id())
+ })?;
+ if output.address != input.owner {
+ bail!("transaction input owner does not match spent output");
+ }
+ total = total
+ .checked_add(output.amount)
+ .context("transaction input total overflows")?;
+ if let Some(root) = utxo_lineage.remove(&input.outpoint) {
+ subtract_lineage_value(lineage_values, &root, output.amount)?;
+ subtract_lineage_owner_value(lineage_owners, &root, &output.address, &input.outpoint)?;
+ inherited_root = newest_lineage_root(inherited_root, Some(root));
+ }
+ }
+ Ok((total, inherited_root))
+}
+
+pub(super) fn spend_blinded_inputs_with_lineage(
+ transaction: &super::BlindedTransaction,
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+ utxo_lineage: &mut BTreeMap<OutPoint, UtxoLineageRoot>,
+ lineage_values: &mut BTreeMap<UtxoLineageRoot, Amount>,
+ lineage_owners: &mut LineageOwnerValues,
+) -> Result<(Vec<TxOutput>, Option<UtxoLineageRoot>)> {
+ let mut locked = Vec::new();
+ let mut seen = std::collections::BTreeSet::new();
+ let mut inherited_root = None;
+ for input in &transaction.inputs {
+ if !seen.insert(input.outpoint.clone()) {
+ bail!("duplicate input in blinded transaction");
+ }
+ let output = utxos.remove(&input.outpoint).with_context(|| {
+ format!(
+ "blinded transaction spends missing output {}",
+ input.outpoint.id()
+ )
+ })?;
+ if output.address != input.owner {
+ bail!("blinded transaction input owner does not match spent output");
+ }
+ if let Some(root) = utxo_lineage.remove(&input.outpoint) {
+ subtract_lineage_value(lineage_values, &root, output.amount)?;
+ subtract_lineage_owner_value(lineage_owners, &root, &output.address, &input.outpoint)?;
+ inherited_root = newest_lineage_root(inherited_root, Some(root));
+ }
+ locked.push(output);
+ }
+ let locked_total = locked.iter().try_fold(0_u64, |total, output| {
+ total
+ .checked_add(output.amount)
+ .context("blinded transaction locked input total overflows")
+ })?;
+ if transaction.fee > locked_total {
+ bail!("blinded transaction fee exceeds locked inputs");
+ }
+ Ok((locked, inherited_root))
+}
+
+pub(super) fn insert_output_with_lineage(
+ outpoint: OutPoint,
+ output: TxOutput,
+ root: Option<UtxoLineageRoot>,
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+ utxo_lineage: &mut BTreeMap<OutPoint, UtxoLineageRoot>,
+ lineage_values: &mut BTreeMap<UtxoLineageRoot, Amount>,
+ lineage_owners: &mut LineageOwnerValues,
+) -> Result<()> {
+ if utxos.insert(outpoint.clone(), output.clone()).is_some() {
+ bail!("created output replaces existing UTXO {}", outpoint.id());
+ }
+ if let Some(root) = root {
+ utxo_lineage.insert(outpoint.clone(), root.clone());
+ let value = lineage_values.entry(root.clone()).or_insert(0);
+ *value = value
+ .checked_add(output.amount)
+ .context("lineage value overflows")?;
+ lineage_owners
+ .entry(root)
+ .or_default()
+ .entry(output.address)
+ .or_default()
+ .insert(outpoint, output.amount);
+ }
+ Ok(())
+}
+
+pub(super) fn newest_lineage_root(
+ left: Option<UtxoLineageRoot>,
+ right: Option<UtxoLineageRoot>,
+) -> Option<UtxoLineageRoot> {
+ match (left, right) {
+ (None, None) => None,
+ (Some(root), None) | (None, Some(root)) => Some(root),
+ (Some(left), Some(right)) => {
+ if (right.height, &right.outpoint) > (left.height, &left.outpoint) {
+ Some(right)
+ } else {
+ Some(left)
+ }
+ }
+ }
+}
+
+pub(super) fn output_lineage_root_for_transaction(
+ transaction: &Transaction,
+ block_height: u64,
+ inherited_root: Option<UtxoLineageRoot>,
+) -> Option<UtxoLineageRoot> {
+ match transaction {
+ Transaction::Mine { .. } => Some(UtxoLineageRoot {
+ outpoint: OutPoint {
+ txid: transaction.signature().to_string(),
+ index: 0,
+ },
+ height: block_height,
+ }),
+ Transaction::Transfer { .. } | Transaction::Burn { .. } => inherited_root,
+ }
+}
+
+fn subtract_lineage_value(
+ lineage_values: &mut BTreeMap<UtxoLineageRoot, Amount>,
+ root: &UtxoLineageRoot,
+ amount: Amount,
+) -> Result<()> {
+ let value = lineage_values
+ .get_mut(root)
+ .context("lineage index is missing spent root")?;
+ *value = value
+ .checked_sub(amount)
+ .context("lineage value underflows")?;
+ if *value == 0 {
+ lineage_values.remove(root);
+ }
+ Ok(())
+}
+
+fn subtract_lineage_owner_value(
+ lineage_owners: &mut LineageOwnerValues,
+ root: &UtxoLineageRoot,
+ owner: &str,
+ outpoint: &OutPoint,
+) -> Result<()> {
+ let owners = lineage_owners
+ .get_mut(root)
+ .context("lineage owner index is missing spent root")?;
+ let outputs = owners
+ .get_mut(owner)
+ .context("lineage owner index is missing spent owner")?;
+ outputs
+ .remove(outpoint)
+ .context("lineage owner index is missing spent output")?;
+ if outputs.is_empty() {
+ owners.remove(owner);
+ }
+ if owners.is_empty() {
+ lineage_owners.remove(root);
+ }
+ Ok(())
+}
diff --git a/src/domain/ledger_mempool.rs b/src/domain/ledger_mempool.rs
@@ -3,8 +3,7 @@ use serde::Serialize;
use super::ledger_ops::{
apply_transaction, ensure_blinded_transaction_fits_empty_block,
- ensure_transaction_fits_empty_block, spend_blinded_inputs, spend_inputs,
- transaction_has_missing_inputs,
+ ensure_transaction_fits_empty_block, spend_blinded_inputs, transaction_has_missing_inputs,
};
use super::transaction::{
BlindedReveal, BlindedTransaction, Transaction, blinded_transaction_inputs_spent_by,
@@ -12,7 +11,7 @@ use super::transaction::{
};
use super::{
Ledger, MAX_ORPHAN_TRANSACTIONS, MAX_PENDING_POOL_BYTES, MAX_PENDING_TRANSACTIONS,
- TransactionSubmitOutcome,
+ TransactionSubmitOutcome, spend_inputs_with_lineage,
};
impl Ledger {
@@ -23,8 +22,20 @@ impl Ledger {
pub(crate) fn reserve_transaction_inputs(&mut self, transaction: &Transaction) -> Result<()> {
self.validate_new_transaction(transaction)?;
let mut utxos = self.utxos.clone();
- spend_inputs(transaction, &mut utxos)?;
+ let mut utxo_lineage = self.utxo_lineage.clone();
+ let mut lineage_values = self.lineage_values.clone();
+ let mut lineage_owners = self.lineage_owners.clone();
+ spend_inputs_with_lineage(
+ transaction,
+ &mut utxos,
+ &mut utxo_lineage,
+ &mut lineage_values,
+ &mut lineage_owners,
+ )?;
self.utxos = utxos;
+ self.utxo_lineage = utxo_lineage;
+ self.lineage_values = lineage_values;
+ self.lineage_owners = lineage_owners;
Ok(())
}
diff --git a/src/domain/ledger_pending.rs b/src/domain/ledger_pending.rs
@@ -3,16 +3,14 @@ use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Context, Result, bail};
use super::blinded::{
- ActiveBlindedTransaction, blinded_envelope_fee_for_transaction, blinded_locked_output_total,
- blinded_reveal_inputs_match, blinded_transaction_commitment, decrypt_blinded_transaction,
- verify_blinded_input_signatures,
+ ActiveBlindedTransaction, blinded_envelope_fee_for_transaction, blinded_reveal_inputs_match,
+ blinded_transaction_commitment, decrypt_blinded_transaction, verify_blinded_input_signatures,
};
use super::ledger_mempool::pending_pool_item_bytes;
use super::ledger_ops::{
apply_spendable_pending_transaction, apply_transaction, best_selectable_blinded_index,
best_selectable_burn_from_index, best_selectable_transaction_index,
- ensure_blinded_transaction_fits_empty_block, ensure_outputs_do_not_overflow,
- ensure_single_input_owner, ensure_transaction_fits_empty_block,
+ ensure_blinded_transaction_fits_empty_block, ensure_transaction_fits_empty_block,
estimated_block_selection_size_bytes, spend_blinded_inputs, spend_spendable_blinded_inputs,
transaction_has_missing_inputs, validate_transaction_inputs, validate_transaction_outputs,
};
@@ -590,48 +588,6 @@ impl Ledger {
Ok(transaction)
}
- pub(super) fn apply_revealed_blinded_transaction(
- &self,
- active: &ActiveBlindedTransaction,
- transaction: &Transaction,
- utxos: &mut BTreeMap<OutPoint, TxOutput>,
- ) -> Result<()> {
- if matches!(transaction, Transaction::Mine { .. }) {
- bail!("mine actions are public and cannot be blinded");
- }
- transaction.verify_signature()?;
- ensure_single_input_owner(transaction)?;
- let input_total = blinded_locked_output_total(active)?;
- let outputs = transaction.outputs();
- let output_total = outputs.iter().try_fold(0_u64, |total, output| {
- total
- .checked_add(output.amount)
- .context("transaction outputs overflow")
- })?;
- let required = output_total
- .checked_add(transaction.fee())
- .context("transaction outputs plus fee overflow")?
- .checked_add(match transaction {
- Transaction::Burn { amount, .. } => *amount,
- Transaction::Transfer { .. } | Transaction::Mine { .. } => 0,
- })
- .context("transaction outputs plus burn overflow")?;
- if input_total != required {
- bail!("blinded transaction inputs do not balance outputs, burn, and fee");
- }
- ensure_outputs_do_not_overflow(utxos, &outputs)?;
- for (index, output) in outputs.iter().enumerate() {
- utxos.insert(
- OutPoint {
- txid: transaction.signature().to_string(),
- index: index as u32,
- },
- output.clone(),
- );
- }
- Ok(())
- }
-
pub(super) fn utxos_after_valid_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> {
let mut utxos = self.utxos.clone();
for pending in self.valid_pending_transactions() {
diff --git a/src/domain/ledger_prepare.rs b/src/domain/ledger_prepare.rs
@@ -65,7 +65,7 @@ impl Ledger {
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_section.reveal_bundle_hashes(height, &prev_hash);
+ let bundle_hashes = reveal_bundle_section.reveal_bundle_hashes(height, &prev_hash, miner);
let vdf_seed = vdf_seed_for_child(&prev_hash, height, &bundle_hashes);
Ok(PreparedBlock {
height,
@@ -139,7 +139,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_section.reveal_bundle_hashes(height, &prev_hash);
+ let bundle_hashes = reveal_bundle_section.reveal_bundle_hashes(height, &prev_hash, miner);
let vdf_seed =
recovery_vdf_seed_for_child(&prev_hash, height, timestamp_ms, &bundle_hashes);
Ok(PreparedBlock {
diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs
@@ -1,6 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Context, Result, bail};
+use sha2::{Digest, Sha256};
use super::blinded::{
ActiveBlindedTransaction, blinded_envelope_fee_for_transaction, decrypt_blinded_transaction,
@@ -13,8 +14,9 @@ use super::ticket::{
};
use super::{
Amount, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, ChainStatus,
- LaunchProfile, Ledger, OutPoint, RevealCommitteeMember, RevealedBlindedTransaction,
- Transaction, TxOutput, UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT, reveal_committee_slot_count,
+ LaunchProfile, Ledger, OutPoint, REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT,
+ RevealCommitteeMember, RevealedBlindedTransaction, Transaction, TxOutput,
+ UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT, UtxoLineageRoot, reveal_committee_slot_count,
reveal_committee_slot_count_for_height,
};
@@ -60,6 +62,7 @@ fn apply_historical_ticket_block(
ActiveBlindedTransaction {
transaction: transaction.clone(),
locked_outputs: Vec::new(),
+ locked_lineage_root: None,
included_height: block.height,
included_by: block.miner.clone(),
},
@@ -68,6 +71,48 @@ fn apply_historical_ticket_block(
Ok(())
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(super) struct LineageCommitteeCandidate {
+ pub(super) root: UtxoLineageRoot,
+ pub(super) value: Amount,
+ pub(super) weight: u64,
+ pub(super) owner: String,
+}
+
+pub(super) fn lineage_committee_weight(value: Amount) -> u64 {
+ u64::BITS as u64 - value.saturating_add(1).leading_zeros() as u64 - 1
+}
+
+pub(super) fn select_weighted_lineage_index(
+ parent: &Block,
+ target_height: u64,
+ slot: u8,
+ candidates: &[LineageCommitteeCandidate],
+) -> Option<usize> {
+ let total_weight = candidates.iter().try_fold(0_u128, |total, candidate| {
+ total.checked_add(u128::from(candidate.weight))
+ })?;
+ if total_weight == 0 {
+ return None;
+ }
+ let seed = format!(
+ "iuna-reveal-lineage-draw-v1:{target_height}:{}:{}:{slot}",
+ parent.hash, parent.vdf_output
+ );
+ let digest = Sha256::digest(seed.as_bytes());
+ let mut bytes = [0_u8; 16];
+ bytes.copy_from_slice(&digest[..16]);
+ let draw = u128::from_be_bytes(bytes) % total_weight;
+ let mut cumulative = 0_u128;
+ for (index, candidate) in candidates.iter().enumerate() {
+ cumulative = cumulative.checked_add(u128::from(candidate.weight))?;
+ if draw < cumulative {
+ return Some(index);
+ }
+ }
+ None
+}
+
impl Ledger {
pub fn snapshot(&self) -> ChainSnapshot {
ChainSnapshot {
@@ -192,6 +237,9 @@ impl Ledger {
pub fn reveal_committee_for_height(&self, height: u64) -> Vec<RevealCommitteeMember> {
let ranked = ranked_tickets_for_height(self.tip(), height, &self.tickets);
+ if height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT {
+ return self.lineage_reveal_committee_for_height(height, ranked);
+ }
let selected = if height < UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT {
let mut selected = Vec::new();
if !ranked.is_empty() {
@@ -240,6 +288,124 @@ impl Ledger {
.collect()
}
+ fn lineage_reveal_committee_for_height(
+ &self,
+ height: u64,
+ ranked: Vec<BurnTicket>,
+ ) -> Vec<RevealCommitteeMember> {
+ let Some(finalizer) = ranked.first() else {
+ return Vec::new();
+ };
+ let mut committee = vec![RevealCommitteeMember {
+ slot: 0,
+ rank: 0,
+ ticket_id: finalizer.id.clone(),
+ owner: finalizer.owner.clone(),
+ amount: finalizer.amount,
+ }];
+ let mut skipped_owners = BTreeSet::from([finalizer.owner.clone()]);
+ let mut remaining = self
+ .eligible_lineage_candidates(finalizer.owner.as_str())
+ .into_iter()
+ .filter_map(|candidate| {
+ let owner =
+ self.representative_owner_for_lineage_root(&candidate.root, &skipped_owners)?;
+ Some(LineageCommitteeCandidate {
+ root: candidate.root,
+ value: candidate.value,
+ weight: candidate.weight,
+ owner,
+ })
+ })
+ .collect::<Vec<_>>();
+
+ for slot in 1..super::REVEAL_COMMITTEE_SIZE {
+ let Some(index) =
+ select_weighted_lineage_index(self.tip(), height, slot as u8, &remaining)
+ else {
+ break;
+ };
+ let selected = remaining.remove(index);
+ skipped_owners.insert(selected.owner.clone());
+ committee.push(RevealCommitteeMember {
+ slot: slot as u8,
+ rank: slot as u32,
+ ticket_id: selected.root.outpoint.id(),
+ owner: selected.owner,
+ amount: selected.value,
+ });
+ remaining.retain(|candidate| {
+ candidate.root != selected.root
+ && self
+ .representative_owner_for_lineage_root(&candidate.root, &skipped_owners)
+ .is_some()
+ });
+ for candidate in &mut remaining {
+ candidate.owner = self
+ .representative_owner_for_lineage_root(&candidate.root, &skipped_owners)
+ .expect("retained lineage candidate has representative owner");
+ }
+ }
+
+ committee
+ }
+
+ fn eligible_lineage_candidates(&self, finalizer: &str) -> Vec<LineageCommitteeCandidate> {
+ let parent_height = self.tip().height;
+ self.lineage_values
+ .iter()
+ .filter(|(root, value)| {
+ **value > 0
+ && root
+ .height
+ .saturating_add(super::REVEAL_LINEAGE_MATURITY_HEIGHTS)
+ <= parent_height
+ && !self.lineage_root_has_owner(root, finalizer)
+ })
+ .filter_map(|(root, value)| {
+ let weight = lineage_committee_weight(*value);
+ (weight > 0).then(|| LineageCommitteeCandidate {
+ root: root.clone(),
+ value: *value,
+ weight,
+ owner: String::new(),
+ })
+ })
+ .collect()
+ }
+
+ fn lineage_root_has_owner(&self, root: &UtxoLineageRoot, owner: &str) -> bool {
+ self.lineage_owners
+ .get(root)
+ .and_then(|owners| owners.get(owner))
+ .is_some_and(|outputs| !outputs.is_empty())
+ }
+
+ fn representative_owner_for_lineage_root(
+ &self,
+ root: &UtxoLineageRoot,
+ skipped_owners: &BTreeSet<String>,
+ ) -> Option<String> {
+ self.lineage_owners.get(root).and_then(|owners| {
+ owners
+ .iter()
+ .filter(|(owner, outputs)| !skipped_owners.contains(*owner) && !outputs.is_empty())
+ .filter_map(|(owner, outputs)| {
+ let (outpoint, amount) = outputs.iter().max_by(|left, right| {
+ left.1.cmp(right.1).then_with(|| right.0.cmp(left.0))
+ })?;
+ Some((owner.clone(), *amount, outpoint.clone()))
+ })
+ .max_by(|left, right| {
+ left.1
+ .cmp(&right.1)
+ .then_with(|| right.2.cmp(&left.2))
+ .then_with(|| right.0.cmp(&left.0))
+ })
+ .map(|(owner, _, _)| owner)
+ })
+ }
+
pub fn genesis_hash(&self) -> &str {
&self.chain[0].hash
}
diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs
@@ -73,8 +73,10 @@ impl Ledger {
&self,
bundles: Vec<RevealBundle>,
) -> RevealBundleSection {
+ let height = self.tip().height + 1;
let signatures = bundles
.iter()
+ .filter(|bundle| height < REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT || bundle.slot != 0)
.map(|bundle| RevealBundleSignature {
slot: bundle.slot,
member: bundle.member.clone(),
@@ -83,7 +85,12 @@ impl Ledger {
.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);
+ let slot_mask =
+ if height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT && bundle.slot == 0 {
+ 0
+ } else {
+ reveal_bundle_slot_mask(bundle.slot).unwrap_or(0)
+ };
for reveal in bundle.reveals {
by_commitment
.entry(reveal.commitment.clone())
@@ -114,7 +121,13 @@ impl Ledger {
finalizer_rank: u32,
section: &RevealBundleSection,
) -> Result<()> {
- if section.signatures.len() > REVEAL_COMMITTEE_SIZE {
+ let explicit_signature_limit =
+ if expected_height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT {
+ REVEAL_COMMITTEE_SIZE.saturating_sub(1)
+ } else {
+ REVEAL_COMMITTEE_SIZE
+ };
+ if section.signatures.len() > explicit_signature_limit {
bail!("block has too many reveal bundle signatures");
}
if section
@@ -136,6 +149,9 @@ impl Ledger {
if usize::from(signature.slot) >= REVEAL_COMMITTEE_SIZE {
bail!("reveal bundle slot is invalid");
}
+ if expected_height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT && signature.slot == 0 {
+ bail!("finalizer reveal attestation must be implicit from height 1500");
+ }
if !seen_slots.insert(signature.slot) {
bail!("duplicate reveal bundle slot");
}
@@ -166,7 +182,9 @@ impl Ledger {
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 {
+ if masked.bundle_mask == 0
+ && expected_height < REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT
+ {
bail!("masked blinded reveal is not assigned to a reveal bundle");
}
if masked.bundle_mask & !reveal_committee_mask() != 0 {
@@ -221,9 +239,13 @@ impl Ledger {
return 0;
}
match finalizer_mode {
- FinalizerMode::Ticket if finalizer_rank == 0 => committee_size,
- FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.min(2),
- FinalizerMode::Ticket => 1,
+ FinalizerMode::Ticket if finalizer_rank == 0 => {
+ required_explicit_reveal_signatures(height, committee_size)
+ }
+ FinalizerMode::Ticket if finalizer_rank == 1 => {
+ required_explicit_reveal_signatures(height, committee_size.min(2))
+ }
+ FinalizerMode::Ticket => required_explicit_reveal_signatures(height, 1),
FinalizerMode::Recovery => 0,
}
}
@@ -300,3 +322,11 @@ impl Ledger {
Ok(bundles)
}
}
+
+fn required_explicit_reveal_signatures(height: u64, attestations: usize) -> usize {
+ if height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT {
+ attestations.saturating_sub(1)
+ } else {
+ attestations
+ }
+}
diff --git a/src/domain/ledger_state.rs b/src/domain/ledger_state.rs
@@ -5,7 +5,7 @@ use std::{
use super::{
ActiveBlindedTransaction, Amount, BlindedReveal, BlindedTransaction, Block, BurnTicket,
- LaunchProfile, OutPoint, Transaction, TxOutput,
+ LaunchProfile, LineageOwnerValues, OutPoint, Transaction, TxOutput, UtxoLineageRoot,
};
#[derive(Clone, Debug)]
@@ -13,6 +13,9 @@ pub struct Ledger {
pub(super) chain: Vec<Block>,
pub(super) genesis_allocations: BTreeMap<String, Amount>,
pub(super) utxos: BTreeMap<OutPoint, TxOutput>,
+ pub(super) utxo_lineage: BTreeMap<OutPoint, UtxoLineageRoot>,
+ pub(super) lineage_values: BTreeMap<UtxoLineageRoot, Amount>,
+ pub(super) lineage_owners: LineageOwnerValues,
pub(super) tickets: Vec<BurnTicket>,
pub(super) pending: Vec<Transaction>,
pub(super) orphans: Vec<Transaction>,
diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs
@@ -18,6 +18,7 @@ pub const REVEAL_COMMITTEE_SIZE: usize = 3;
pub const UNIQUE_OWNER_REVEAL_COMMITTEE_HEIGHT: u64 = 500;
pub const MAX_REVEAL_BUNDLE_BYTES: usize = 10_000;
pub const REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT: u64 = 1_500;
+pub const REVEAL_LINEAGE_MATURITY_HEIGHTS: u64 = 20;
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;
diff --git a/src/domain/reveal.rs b/src/domain/reveal.rs
@@ -1,7 +1,10 @@
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
-use super::{Amount, BlindedReveal, REVEAL_COMMITTEE_SIZE, hex_hash};
+use super::{
+ Amount, BlindedReveal, REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT, REVEAL_COMMITTEE_SIZE,
+ hex_hash,
+};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -105,9 +108,14 @@ impl RevealBundleSection {
&self,
height: u64,
prev_hash: &str,
+ finalizer: &str,
) -> [String; REVEAL_COMMITTEE_SIZE] {
let bundles = self.expand(height, prev_hash);
- reveal_bundle_hashes(&bundles)
+ let mut hashes = reveal_bundle_hashes(&bundles);
+ if height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT {
+ hashes[0] = finalizer_attestation_hash(height, prev_hash, finalizer, &self.reveals);
+ }
+ hashes
}
pub(super) fn canonical(&self) -> String {
@@ -191,6 +199,22 @@ pub(super) fn reveal_bundle_hashes(bundles: &[RevealBundle]) -> [String; REVEAL_
})
}
+pub(super) fn finalizer_attestation_hash(
+ height: u64,
+ prev_hash: &str,
+ finalizer: &str,
+ reveals: &[MaskedBlindedReveal],
+) -> String {
+ let canonical_reveals = reveals
+ .iter()
+ .map(|masked| masked.reveal.canonical())
+ .collect::<Vec<_>>()
+ .join("|");
+ hex_hash(format!(
+ "iuna-finalizer-reveal-attestation-v1:{height}:{prev_hash}:{finalizer}:{canonical_reveals}"
+ ))
+}
+
pub(super) fn canonical_reveal_bundle_hashes(
bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE],
) -> String {
diff --git a/src/domain/tests.rs b/src/domain/tests.rs
@@ -1,4 +1,7 @@
use super::*;
+use crate::domain::ledger_queries::{
+ LineageCommitteeCandidate, lineage_committee_weight, select_weighted_lineage_index,
+};
#[test]
fn target_block_time_is_five_minutes() {
@@ -19,6 +22,39 @@ fn named_test_outpoint(name: &str) -> OutPoint {
}
}
+fn test_lineage_root(name: &str, height: u64) -> UtxoLineageRoot {
+ UtxoLineageRoot {
+ outpoint: named_test_outpoint(name),
+ height,
+ }
+}
+
+fn insert_lineage_utxo(
+ ledger: &mut Ledger,
+ outpoint: OutPoint,
+ wallet: &Wallet,
+ amount: Amount,
+ root: UtxoLineageRoot,
+) {
+ ledger.utxos.insert(
+ outpoint.clone(),
+ TxOutput {
+ address: wallet.address().to_string(),
+ amount,
+ },
+ );
+ ledger.utxo_lineage.insert(outpoint.clone(), root.clone());
+ let value = ledger.lineage_values.entry(root.clone()).or_insert(0);
+ *value = value.checked_add(amount).unwrap();
+ ledger
+ .lineage_owners
+ .entry(root)
+ .or_default()
+ .entry(wallet.address().to_string())
+ .or_default()
+ .insert(outpoint, amount);
+}
+
fn ledger_with_wallet_utxos(wallet: &Wallet, amounts: &[Amount]) -> Ledger {
let mut ledger = Ledger::new(BTreeMap::new(), 1);
ledger.utxos = amounts
@@ -283,7 +319,8 @@ fn prepare_active_blinded_burn_for_reveal_thresholds_at_next_height(
let bob = Wallet::from_seed(seeds[1]);
let carol = Wallet::from_seed(seeds[2]);
let victim = Wallet::from_seed(seeds[3]);
- let finalizers = vec![attacker.clone(), bob, carol];
+ let finalizers = vec![attacker.clone(), bob.clone(), carol.clone()];
+ let wallets = vec![attacker.clone(), bob, carol, victim.clone()];
let mut ledger = ledger_with_finalizers(
&finalizers,
&[(&attacker, 100 * MICRO_IUNA), (&victim, 20 * MICRO_IUNA)],
@@ -305,9 +342,28 @@ fn prepare_active_blinded_burn_for_reveal_thresholds_at_next_height(
ledger
.submit_blinded_reveal(blinded.reveal.clone())
.unwrap();
+ if next_height >= REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT {
+ let mature_root_height = next_height.saturating_sub(REVEAL_LINEAGE_MATURITY_HEIGHTS + 1);
+ for (index, wallet) in finalizers.iter().enumerate().skip(1) {
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint(&format!("threshold-lineage-{index}")),
+ wallet,
+ MICRO_IUNA,
+ test_lineage_root(&format!("threshold-root-{index}"), mature_root_height),
+ );
+ }
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("threshold-lineage-victim"),
+ &victim,
+ MICRO_IUNA,
+ test_lineage_root("threshold-root-victim", mature_root_height),
+ );
+ }
assert_eq!(ledger.height() + 1, next_height);
assert_eq!(ledger.reveal_committee_for_next_block().len(), 3);
- (ledger, finalizers, blinded)
+ (ledger, wallets, blinded)
}
fn install_finalizer_tickets_for_height(ledger: &mut Ledger, finalizers: &[Wallet], height: u64) {
@@ -571,6 +627,7 @@ fn blinded_fee_split_burns_rounding_dust() {
payload_hash: "04".repeat(32),
},
locked_outputs: Vec::new(),
+ locked_lineage_root: None,
included_height: 1,
included_by: committer.address().to_string(),
};
@@ -614,6 +671,7 @@ fn blinded_fee_split_pays_no_reveal_finalizer_without_signed_reveal_lists() {
payload_hash: "04".repeat(32),
},
locked_outputs: Vec::new(),
+ locked_lineage_root: None,
included_height: 1,
included_by: committer.address().to_string(),
};
@@ -665,6 +723,7 @@ fn blinded_fee_split_pays_committer_executor_and_reveal_bundle_signers() {
payload_hash: "04".repeat(32),
},
locked_outputs: Vec::new(),
+ locked_lineage_root: None,
included_height: 1,
included_by: committer.address().to_string(),
};
@@ -1930,14 +1989,14 @@ fn rank0_finalizer_needs_all_reveal_bundle_signatures_when_blinded_is_active() {
let mut missing_one = ledger.clone();
let error =
- try_mine_rank_with_reveal_bundles(&mut missing_one, &rank0, 1, 2, bundles[..2].to_vec())
+ try_mine_rank_with_reveal_bundles(&mut missing_one, &rank0, 1, 2, bundles[..1].to_vec())
.unwrap_err();
- assert!(format!("{error:#}").contains("got 2, need 3"));
+ assert!(format!("{error:#}").contains("got 0, need 2"));
let mut complete = ledger;
let block = try_mine_rank_with_reveal_bundles(&mut complete, &rank0, 1, 2, bundles).unwrap();
assert_eq!(block.finalizer_rank, 0);
- assert_eq!(block.included_reveal_bundle_count(), 3);
+ assert_eq!(block.included_reveal_bundle_count(), 2);
assert_eq!(block.all_blinded_reveals().len(), 1);
}
@@ -1959,10 +2018,10 @@ fn rank1_finalizer_needs_two_reveal_bundle_signatures_when_blinded_is_active() {
&rank1,
1,
VDF_TARGET_BLOCK_MS * 2,
- bundles[..1].to_vec(),
+ Vec::new(),
)
.unwrap_err();
- assert!(format!("{error:#}").contains("got 1, need 2"));
+ assert!(format!("{error:#}").contains("got 0, need 1"));
let mut enough = ledger;
let block = try_mine_rank_with_reveal_bundles(
@@ -1970,15 +2029,15 @@ fn rank1_finalizer_needs_two_reveal_bundle_signatures_when_blinded_is_active() {
&rank1,
1,
VDF_TARGET_BLOCK_MS * 2,
- bundles[..2].to_vec(),
+ bundles[1..2].to_vec(),
)
.unwrap();
assert_eq!(block.finalizer_rank, 1);
- assert_eq!(block.included_reveal_bundle_count(), 2);
+ assert_eq!(block.included_reveal_bundle_count(), 1);
}
#[test]
-fn rank2_finalizer_can_publish_one_reveal_bundle_signature_when_blinded_is_active() {
+fn rank2_finalizer_can_publish_without_explicit_reveal_bundle_signature_when_blinded_is_active() {
let (ledger, finalizers, _) = prepare_active_blinded_burn_for_reveal_thresholds([
"reveal-threshold-r2-attacker",
"reveal-threshold-r2-bob",
@@ -1995,11 +2054,11 @@ fn rank2_finalizer_can_publish_one_reveal_bundle_signature_when_blinded_is_activ
&rank2,
1,
VDF_TARGET_BLOCK_MS * 4,
- bundles[..1].to_vec(),
+ Vec::new(),
)
.unwrap();
assert_eq!(block.finalizer_rank, 2);
- assert_eq!(block.included_reveal_bundle_count(), 1);
+ assert_eq!(block.included_reveal_bundle_count(), 0);
}
#[test]
@@ -2015,7 +2074,119 @@ fn active_blinded_envelope_rejects_own_burn_only_block_without_reveal_list_thres
let error =
mine_own_burn_only_without_reveal_bundles(&mut ledger, &rank1, 1, VDF_TARGET_BLOCK_MS * 2)
.unwrap_err();
- assert!(format!("{error:#}").contains("got 0, need 2"));
+ assert!(format!("{error:#}").contains("got 0, need 1"));
+}
+
+#[test]
+fn explicit_slot_zero_reveal_signature_is_rejected_from_height_1500() {
+ let (mut ledger, finalizers, _) = prepare_active_blinded_burn_for_reveal_thresholds([
+ "reveal-slot0-reject-attacker",
+ "reveal-slot0-reject-bob",
+ "reveal-slot0-reject-carol",
+ "reveal-slot0-reject-victim",
+ ]);
+ let rank0 = next_rank_wallet(&ledger, &finalizers, 0).clone();
+ let bundles = reveal_bundles_for_next_block(&ledger, &finalizers);
+ let burn = ledger.build_burn(&rank0, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let prepared = ledger
+ .prepare_next_block_with_reveal_bundles(rank0.address(), 1, bundles.clone())
+ .unwrap();
+ let mut block = prepared.finish(&rank0, "preverified-vdf".to_string());
+ block.reveal_bundle_section.signatures.insert(
+ 0,
+ RevealBundleSignature {
+ slot: bundles[0].slot,
+ member: bundles[0].member.clone(),
+ signature: bundles[0].signature.clone(),
+ },
+ );
+ block.reveal_bundle_section.signatures.pop();
+ let error = ledger
+ .validate_reveal_bundle_section_for_block(
+ block.height,
+ &block.prev_hash,
+ block.finalizer_mode,
+ block.finalizer_rank,
+ &block.reveal_bundle_section,
+ )
+ .unwrap_err();
+
+ assert!(
+ format!("{error:#}").contains("finalizer reveal attestation must be implicit"),
+ "{error:#}"
+ );
+}
+
+#[test]
+fn finalizer_reveal_attestation_changes_vdf_seed_without_explicit_signature() {
+ let (mut ledger, finalizers, _) = prepare_active_blinded_burn_for_reveal_thresholds([
+ "reveal-synthetic-seed-attacker",
+ "reveal-synthetic-seed-bob",
+ "reveal-synthetic-seed-carol",
+ "reveal-synthetic-seed-victim",
+ ]);
+ let rank0 = next_rank_wallet(&ledger, &finalizers, 0).clone();
+ let bundles = reveal_bundles_for_next_block(&ledger, &finalizers);
+ let burn = ledger.build_burn(&rank0, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let prepared = ledger
+ .prepare_next_block_with_reveal_bundles(rank0.address(), 1, bundles)
+ .unwrap();
+ let block = prepared.finish(&rank0, "preverified-vdf".to_string());
+ let mut other_finalizer = block.clone();
+ other_finalizer.miner = Wallet::from_seed("reveal-synthetic-other")
+ .address()
+ .to_string();
+
+ assert!(
+ block
+ .reveal_bundle_section
+ .signatures
+ .iter()
+ .all(|signature| signature.slot != 0)
+ );
+ assert_ne!(
+ block.reveal_bundle_hashes()[0],
+ default_reveal_bundle_hash(0)
+ );
+ assert_ne!(
+ block.reveal_bundle_hashes()[0],
+ other_finalizer.reveal_bundle_hashes()[0]
+ );
+ assert_ne!(block.vdf_seed(), other_finalizer.vdf_seed());
+}
+
+#[test]
+fn reveal_fee_count_includes_implicit_finalizer_attestation_from_height_1500() {
+ let reveal = BlindedReveal {
+ commitment: "implicit-fee-count".to_string(),
+ key: "key".to_string(),
+ };
+ let section = RevealBundleSection {
+ signatures: Vec::new(),
+ reveals: vec![MaskedBlindedReveal {
+ reveal,
+ bundle_mask: 0,
+ }],
+ };
+
+ assert_eq!(
+ reveal_fee_bundle_count_for_height(
+ REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT,
+ §ion,
+ §ion.reveals[0],
+ ),
+ 1
+ );
+ assert!(
+ reveal_fee_signatures_for_height(
+ REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT,
+ §ion,
+ §ion.reveals[0],
+ )
+ .is_empty()
+ );
}
#[test]
@@ -2638,6 +2809,445 @@ fn reveal_committee_uses_unique_ticket_owners_from_height_500() {
assert_eq!(committee.first().map(|member| member.rank), Some(0));
}
+fn lineage_committee_test_ledger(finalizer: &Wallet) -> Ledger {
+ let mut ledger = Ledger::new(BTreeMap::new(), 1);
+ set_tip_height_for_validation(&mut ledger, REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT - 1);
+ ledger.tickets = vec![BurnTicket {
+ id: "finalizer-ticket".to_string(),
+ owner: finalizer.address().to_string(),
+ amount: MICRO_IUNA,
+ eligible_from_height: REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT,
+ eligible_until_height: REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT,
+ }];
+ ledger
+}
+
+#[test]
+fn reveal_committee_uses_lineage_roots_from_height_1500_without_split_multiplier() {
+ let finalizer = Wallet::from_seed("lineage-split-finalizer");
+ let bob = Wallet::from_seed("lineage-split-bob");
+ let carol = Wallet::from_seed("lineage-split-carol");
+ let dave = Wallet::from_seed("lineage-split-dave");
+ let mut ledger = lineage_committee_test_ledger(&finalizer);
+ let root_a = test_lineage_root("split-root-a", 1_470);
+ let root_b = test_lineage_root("split-root-b", 1_470);
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("split-a-0"),
+ &bob,
+ 5 * MICRO_IUNA,
+ root_a.clone(),
+ );
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("split-a-1"),
+ &carol,
+ 5 * MICRO_IUNA,
+ root_a.clone(),
+ );
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("split-b-0"),
+ &dave,
+ 5 * MICRO_IUNA,
+ root_b,
+ );
+
+ let committee = ledger.reveal_committee_for_height(REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT);
+ let roots = committee
+ .iter()
+ .skip(1)
+ .map(|member| member.ticket_id.as_str())
+ .collect::<BTreeSet<_>>();
+
+ assert_eq!(committee.len(), 3);
+ assert_eq!(roots.len(), committee.len() - 1);
+ assert!(roots.contains(root_a.outpoint.id().as_str()));
+}
+
+#[test]
+fn reveal_committee_excludes_finalizer_owned_lineage_roots_from_extra_slots() {
+ let finalizer = Wallet::from_seed("lineage-finalizer-root-finalizer");
+ let bob = Wallet::from_seed("lineage-finalizer-root-bob");
+ let mut ledger = lineage_committee_test_ledger(&finalizer);
+ let finalizer_root = test_lineage_root("finalizer-root", 1_470);
+ let bob_root = test_lineage_root("bob-root", 1_470);
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("finalizer-root-utxo"),
+ &finalizer,
+ 100 * MICRO_IUNA,
+ finalizer_root.clone(),
+ );
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("bob-root-utxo"),
+ &bob,
+ MICRO_IUNA,
+ bob_root.clone(),
+ );
+
+ let committee = ledger.reveal_committee_for_height(REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT);
+
+ assert_eq!(committee.len(), 2);
+ assert_eq!(committee[0].owner, finalizer.address());
+ assert_eq!(committee[1].owner, bob.address());
+ assert_eq!(committee[1].ticket_id, bob_root.outpoint.id());
+ assert_ne!(committee[1].ticket_id, finalizer_root.outpoint.id());
+}
+
+#[test]
+fn reveal_committee_excludes_root_even_when_finalizer_split_to_another_owner() {
+ let finalizer = Wallet::from_seed("lineage-finalizer-split-finalizer");
+ let bob = Wallet::from_seed("lineage-finalizer-split-bob");
+ let carol = Wallet::from_seed("lineage-finalizer-split-carol");
+ let mut ledger = lineage_committee_test_ledger(&finalizer);
+ let finalizer_root = test_lineage_root("finalizer-split-root", 1_470);
+ let carol_root = test_lineage_root("carol-root", 1_470);
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("finalizer-owned-root-piece"),
+ &finalizer,
+ MICRO_IUNA,
+ finalizer_root.clone(),
+ );
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("bob-owned-root-piece"),
+ &bob,
+ 100 * MICRO_IUNA,
+ finalizer_root.clone(),
+ );
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("carol-root-utxo"),
+ &carol,
+ MICRO_IUNA,
+ carol_root.clone(),
+ );
+
+ let committee = ledger.reveal_committee_for_height(REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT);
+
+ assert_eq!(committee.len(), 2);
+ assert_eq!(committee[1].ticket_id, carol_root.outpoint.id());
+ assert_ne!(committee[1].ticket_id, finalizer_root.outpoint.id());
+}
+
+#[test]
+fn lineage_merge_inherits_newest_root_and_tie_breaks_by_outpoint() {
+ let alice = Wallet::from_seed("lineage-merge-alice");
+ let old_root = test_lineage_root("merge-old-root", 1_460);
+ let new_root = test_lineage_root("merge-new-root", 1_470);
+ let tie_left = test_lineage_root("merge-tie-left", 1_470);
+ let tie_right = test_lineage_root("merge-tie-right", 1_470);
+
+ assert_eq!(
+ newest_lineage_root(Some(old_root.clone()), Some(new_root.clone())),
+ Some(new_root)
+ );
+ assert_eq!(
+ newest_lineage_root(Some(tie_left.clone()), Some(tie_right.clone())),
+ Some(tie_left.max(tie_right))
+ );
+
+ let mut ledger = Ledger::new(BTreeMap::new(), 1);
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("merge-old-input"),
+ &alice,
+ 3,
+ old_root,
+ );
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("merge-new-input"),
+ &alice,
+ 4,
+ test_lineage_root("merge-applied-new-root", 1_480),
+ );
+ let transaction = Transaction::Transfer {
+ inputs: vec![
+ TxInput {
+ outpoint: named_test_outpoint("merge-old-input"),
+ owner: alice.address().to_string(),
+ signature: "genesis".to_string(),
+ },
+ TxInput {
+ outpoint: named_test_outpoint("merge-new-input"),
+ owner: alice.address().to_string(),
+ signature: "genesis".to_string(),
+ },
+ ],
+ outputs: vec![TxOutput {
+ address: alice.address().to_string(),
+ amount: 7,
+ }],
+ fee: 0,
+ signature: "lineage-merge-transfer".to_string(),
+ };
+ let (_, inherited) = spend_inputs_with_lineage(
+ &transaction,
+ &mut ledger.utxos,
+ &mut ledger.utxo_lineage,
+ &mut ledger.lineage_values,
+ &mut ledger.lineage_owners,
+ )
+ .unwrap();
+
+ assert_eq!(inherited.unwrap().height, 1_480);
+}
+
+#[test]
+fn lineage_representative_owner_uses_largest_unspent_output_for_root() {
+ let finalizer = Wallet::from_seed("lineage-representative-finalizer");
+ let bob = Wallet::from_seed("lineage-representative-bob");
+ let carol = Wallet::from_seed("lineage-representative-carol");
+ let mut ledger = lineage_committee_test_ledger(&finalizer);
+ let root = test_lineage_root("representative-root", 1_470);
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("representative-small"),
+ &bob,
+ MICRO_IUNA,
+ root.clone(),
+ );
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("representative-large"),
+ &carol,
+ 2 * MICRO_IUNA,
+ root,
+ );
+
+ let committee = ledger.reveal_committee_for_height(REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT);
+
+ assert_eq!(committee.len(), 2);
+ assert_eq!(committee[1].owner, carol.address());
+}
+
+#[test]
+fn lineage_committee_reassigns_root_representative_after_owner_is_selected() {
+ let finalizer = Wallet::from_seed("lineage-reassign-finalizer");
+ let bob = Wallet::from_seed("lineage-reassign-bob");
+ let carol = Wallet::from_seed("lineage-reassign-carol");
+ let mut ledger = lineage_committee_test_ledger(&finalizer);
+ let mut roots = [
+ test_lineage_root("reassign-root-a", 1_470),
+ test_lineage_root("reassign-root-b", 1_470),
+ ];
+ roots.sort();
+ let first_index = select_weighted_lineage_index(
+ ledger.tip(),
+ REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT,
+ 1,
+ &roots
+ .iter()
+ .cloned()
+ .map(|root| LineageCommitteeCandidate {
+ root,
+ value: 10 * MICRO_IUNA,
+ weight: lineage_committee_weight(10 * MICRO_IUNA),
+ owner: bob.address().to_string(),
+ })
+ .collect::<Vec<_>>(),
+ )
+ .unwrap();
+ let selected_first_root = roots[first_index].clone();
+ let fallback_root = roots[1 - first_index].clone();
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("reassign-selected-first"),
+ &bob,
+ 10 * MICRO_IUNA,
+ selected_first_root,
+ );
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("reassign-fallback-bob"),
+ &bob,
+ 9 * MICRO_IUNA,
+ fallback_root.clone(),
+ );
+ insert_lineage_utxo(
+ &mut ledger,
+ named_test_outpoint("reassign-fallback-carol"),
+ &carol,
+ MICRO_IUNA,
+ fallback_root.clone(),
+ );
+
+ let committee = ledger.reveal_committee_for_height(REVEAL_BUNDLE_SIGNATURE_THRESHOLDS_HEIGHT);
+
+ assert_eq!(committee.len(), 3);
+ assert_eq!(committee[1].owner, bob.address());
+ assert_eq!(committee[2].owner, carol.address());
+ assert_eq!(committee[2].ticket_id, fallback_root.outpoint.id());
+}
+
+#[test]
+fn reserved_transaction_inputs_update_lineage_indexes() {
+ let alice = Wallet::from_seed("lineage-reserve-alice");
+ let mut ledger = Ledger::new(BTreeMap::new(), 1);
+ let outpoint = named_test_outpoint("lineage-reserve-input");
+ let root = test_lineage_root("lineage-reserve-root", 1);
+ insert_lineage_utxo(
+ &mut ledger,
+ outpoint.clone(),
+ &alice,
+ MICRO_IUNA,
+ root.clone(),
+ );
+ let burn = ledger
+ .build_burn_with_inputs(&alice, MICRO_IUNA, 0, &[outpoint.clone()])
+ .unwrap();
+
+ ledger.reserve_transaction_inputs(&burn).unwrap();
+
+ assert!(!ledger.utxos.contains_key(&outpoint));
+ assert!(!ledger.utxo_lineage.contains_key(&outpoint));
+ assert!(!ledger.lineage_values.contains_key(&root));
+ assert!(!ledger.lineage_owners.contains_key(&root));
+}
+
+#[test]
+fn lineage_cache_roundtrips_through_snapshot_replay() {
+ let alice = Wallet::from_seed("lineage-snapshot-alice");
+ let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
+
+ apply_preverified_burn_block_with_mines(&mut ledger, &alice, 1);
+ let restored =
+ Ledger::from_snapshot_with_vdf_policy(ledger.snapshot(), false, u64::MAX).unwrap();
+
+ assert!(!ledger.utxo_lineage.is_empty());
+ assert_eq!(restored.utxo_lineage, ledger.utxo_lineage);
+ assert_eq!(restored.lineage_values, ledger.lineage_values);
+ assert_eq!(restored.lineage_owners, ledger.lineage_owners);
+}
+
+#[test]
+fn lineage_cache_is_replaced_on_snapshot_reorg() {
+ let alice = Wallet::from_seed("lineage-reorg-alice");
+ let common = ledger_with_allocation(&alice, 20 * MICRO_IUNA);
+ let mut local = common.clone();
+ let mut remote = common;
+
+ apply_preverified_burn_block_with_mines(&mut local, &alice, 1);
+ apply_preverified_burn_block_with_mines(&mut remote, &alice, 2);
+ apply_preverified_burn_block_with_mines(&mut remote, &alice, 1);
+
+ assert!(
+ local
+ .extend_from_snapshot_with_vdf_policy(remote.snapshot(), false, u64::MAX)
+ .unwrap()
+ );
+ assert_eq!(local.utxo_lineage, remote.utxo_lineage);
+ assert_eq!(local.lineage_values, remote.lineage_values);
+ assert_eq!(local.lineage_owners, remote.lineage_owners);
+}
+
+#[test]
+fn transfer_output_inherits_mine_lineage_through_block_apply() {
+ let alice = Wallet::from_seed("lineage-transfer-apply-alice");
+ let bob = Wallet::from_seed("lineage-transfer-apply-bob");
+ let finalizers = [alice.clone()];
+ let mut ledger = ledger_with_finalizers(&finalizers, &[(&bob, 10 * MICRO_IUNA)]);
+ let mine = ledger.build_mine(bob.address()).unwrap();
+ let mine_outpoint = OutPoint {
+ txid: mine.signature().to_string(),
+ index: 0,
+ };
+ ledger.submit_transaction(mine).unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
+
+ let transfer = ledger
+ .build_transfer_with_inputs(
+ &bob,
+ alice.address(),
+ MINE_REWARD,
+ 0,
+ &[mine_outpoint.clone()],
+ )
+ .unwrap();
+ let transfer_outpoint = OutPoint {
+ txid: transfer.signature().to_string(),
+ index: 0,
+ };
+ ledger.submit_transaction(transfer).unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, 2);
+
+ assert_eq!(
+ ledger.utxo_lineage.get(&transfer_outpoint),
+ Some(&UtxoLineageRoot {
+ outpoint: mine_outpoint,
+ height: 1,
+ })
+ );
+}
+
+#[test]
+fn blinded_reveal_output_inherits_locked_input_lineage_through_block_apply() {
+ let alice = Wallet::from_seed("lineage-blinded-apply-alice");
+ let bob = Wallet::from_seed("lineage-blinded-apply-bob");
+ let carol = Wallet::from_seed("lineage-blinded-apply-carol");
+ let finalizers = [alice.clone(), bob.clone()];
+ let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
+ let mine = ledger.build_mine(carol.address()).unwrap();
+ let mine_outpoint = OutPoint {
+ txid: mine.signature().to_string(),
+ index: 0,
+ };
+ ledger.submit_transaction(mine).unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
+ mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
+
+ let transfer = ledger
+ .build_transfer_with_inputs(
+ &carol,
+ bob.address(),
+ MINE_REWARD,
+ 0,
+ &[mine_outpoint.clone()],
+ )
+ .unwrap();
+ let transfer_outpoint = OutPoint {
+ txid: transfer.signature().to_string(),
+ index: 0,
+ };
+ let blinded = ledger
+ .build_blinded_transaction(&carol, transfer, 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, 2);
+
+ assert_eq!(
+ ledger
+ .active_blinded
+ .get(&blinded.transaction.commitment)
+ .and_then(|active| active.locked_lineage_root.as_ref()),
+ Some(&UtxoLineageRoot {
+ outpoint: mine_outpoint.clone(),
+ height: 1,
+ })
+ );
+
+ ledger.submit_blinded_reveal(blinded.reveal).unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
+ mine_preverified_as_next_leader_with_reveal_bundles(&mut ledger, &finalizers, 3);
+
+ assert_eq!(
+ ledger.utxo_lineage.get(&transfer_outpoint),
+ Some(&UtxoLineageRoot {
+ outpoint: mine_outpoint,
+ height: 1,
+ })
+ );
+}
+
#[test]
fn reveal_bundle_validation_rejects_wrong_signature_and_slot() {
let alice = Wallet::from_seed("bundle-invalid-alice");