commit 6b35a3cc92c66fda141cb70b2bba2087286858d5
parent dc776d0f0e2aea0af56fe70082fa5dfe9580a984
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Mon, 3 Aug 2026 09:44:44 +0200
Enforce finalizer rank time slots
Diffstat:
7 files changed, 358 insertions(+), 50 deletions(-)
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -41,7 +41,7 @@ For each block height, eligible tickets are ranked:
- Rank `0` is the primary finalizer.
- Rank `1`, `2`, and later ranks are fallback finalizers.
-The selected finalizer must prove ownership of the selected ticket and run the required VDF work. A block is valid only if the finalizer matches its ranked ticket, carries the correct leader proof, includes a valid VDF output, and follows the transaction selection rules.
+The selected finalizer must prove ownership of the selected ticket, respect its rank time slot, and run the required VDF work. A block is valid only if the finalizer matches its ranked ticket, carries the correct leader proof, has a valid timestamp for its rank, includes a valid VDF output, and follows the transaction selection rules.
Every normal block must include at least one burn transaction. The finalizer reward for the block is the sum of transaction fees in that block.
@@ -52,13 +52,33 @@ The VDF is there to make block production sequential and time-based. It cannot b
The target block time is `10 minutes`. The protocol retargets VDF rounds from recent observed block times:
- It uses a `20` block observation window.
-- It ignores recovery blocks for retargeting.
-- It adjusts fallback blocks by finalizer rank, so a rank `1` fallback block does not look twice as slow just because it had to wait for twice the VDF work.
+- It uses rank `0` ticket blocks for retargeting.
+- It ignores fallback and recovery blocks for retargeting because their timestamps include intentional waiting.
- It has a `10%` deadband and a maximum `2%` retarget step per adjustment.
- Extremely fast or slow samples are clamped before they affect the next target.
Fallback finalizers use more VDF work: rank `0` uses the base rounds, rank `1` uses `2x`, rank `2` uses `3x`, and so on. This gives the primary finalizer the first chance while still allowing the network to move if the primary does not publish.
+VDF rounds alone are not the fallback gate. Faster hardware could otherwise finish a lower-ranked VDF before a slower primary finalizer. iuna therefore also uses rank time slots:
+
+- rank `0` blocks are valid as soon as their timestamp is greater than the parent timestamp;
+- rank `1` blocks are valid from `parent timestamp + 1 * target block time`;
+- rank `2` blocks are valid from `parent timestamp + 2 * target block time`;
+- and so on.
+
+If a fallback finalizer finishes the VDF early, it must wait until its slot opens before publishing. Rank `0` does not wait on a rank slot; that keeps the primary path useful as the clean VDF-speed signal for retargeting. If a rank `0` finalizer finishes late, the block timestamp should reflect that later completion/publication time so VDF retargeting can observe slow rounds. Other nodes reject fallback blocks whose timestamp is before their rank slot.
+
+## Timestamp Checks
+
+Rank slots depend on block timestamps, so timestamps are constrained by consensus:
+
+- a block timestamp must be greater than its parent timestamp;
+- it must exceed median-time-past;
+- it must not be too far in the future relative to the validating node's network-adjusted clock;
+- for fallback ticket blocks, it must be at or after the finalizer rank slot.
+
+The current future drift limit is `2 minutes`. A finalizer can lie within that small margin, but cannot skip an entire `10 minute` rank slot by claiming a far-future timestamp. P2P treats too-early future/slot blocks as temporal errors rather than peer-banning evidence.
+
## Recovery Blocks
If selected ticket finalizers do not publish for long enough, recovery finalization becomes available. The current delay is `6` target block times.
diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs
@@ -2775,12 +2775,12 @@ async fn record_inbound_result(
Err(error) => {
let message = format!("{error:#}");
if known_peer.is_some() {
- network
- .inner
- .peers
- .lock()
- .await
- .record_misbehavior(&peer, message.clone());
+ let mut peers = network.inner.peers.lock().await;
+ if inbound_error_counts_as_misbehavior(&message) {
+ peers.record_misbehavior(&peer, message.clone());
+ } else {
+ peers.record_inbound_error(&peer, message.clone());
+ }
}
if debug_logging_enabled() {
eprintln!("p2p envelope from {peer} ignored: {message}");
@@ -2922,6 +2922,11 @@ fn is_possible_fork_error(error: &anyhow::Error) -> bool {
|| message.contains("expected block height")
}
+fn inbound_error_counts_as_misbehavior(message: &str) -> bool {
+ !message.contains("block timestamp is too far in the future")
+ && !message.contains("block timestamp is before finalizer rank")
+}
+
#[cfg(test)]
mod tests {
use std::{
@@ -3629,6 +3634,60 @@ mod tests {
}
}
+ #[test]
+ fn temporal_block_rejections_do_not_score_peer_misbehavior() {
+ for reason in [
+ "block timestamp is too far in the future",
+ "block timestamp is before finalizer rank 1 time slot 1200000",
+ ] {
+ assert!(
+ !super::inbound_error_counts_as_misbehavior(reason),
+ "{reason} should be treated as temporal"
+ );
+ }
+
+ assert!(super::inbound_error_counts_as_misbehavior(
+ "block VDF output is invalid"
+ ));
+ }
+
+ #[tokio::test]
+ async fn temporal_block_rejection_records_error_without_banning_peer() {
+ let wallet = Wallet::from_seed("temporal-block-peer-wallet");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(wallet.address().to_string(), 1_000);
+ let ledger = Ledger::new(allocations, 100);
+ let node = Arc::new(tokio::sync::Mutex::new(NodeCore::from_ledger(
+ wallet, ledger, 0,
+ )));
+ let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![
+ "127.0.0.1:9444".to_string(),
+ ])));
+ let network = super::GossipNetwork::new_for_tests(node, Arc::clone(&peers));
+ let known_peer = Some("127.0.0.1:9444".to_string());
+ let remote_addr: SocketAddr = "127.0.0.1:50000".parse().unwrap();
+
+ super::record_inbound_result(
+ &network,
+ &known_peer,
+ remote_addr,
+ Err(anyhow::anyhow!("block timestamp is too far in the future")),
+ )
+ .await;
+
+ let peers = peers.lock().await;
+ let peer = peers
+ .list()
+ .into_iter()
+ .find(|peer| peer.address == "127.0.0.1:9444")
+ .unwrap();
+ assert_eq!(peer.misbehavior_score, 0);
+ assert_eq!(
+ peer.last_error.as_deref(),
+ Some("block timestamp is too far in the future")
+ );
+ }
+
#[tokio::test]
async fn inventory_requests_only_missing_objects() {
let alice = Wallet::from_seed("missing-inv-alice");
diff --git a/src/app.rs b/src/app.rs
@@ -944,7 +944,7 @@ impl NodeCore {
return outcome;
};
let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds());
- match self.complete_prepared_block(work, vdf_output) {
+ match self.complete_prepared_block_at(work, vdf_output, timestamp_ms) {
Ok(block) => {
outcome.block = Some(block);
outcome.skipped_reason = None;
@@ -1264,7 +1264,16 @@ impl NodeCore {
work: PreparedBlock,
vdf_output: String,
) -> Result<Block> {
- let block = work.finish(self.wallet.unlocked()?, vdf_output);
+ self.complete_prepared_block_at(work, vdf_output, now_ms())
+ }
+
+ pub fn complete_prepared_block_at(
+ &mut self,
+ work: PreparedBlock,
+ vdf_output: String,
+ timestamp_ms: u64,
+ ) -> Result<Block> {
+ let block = work.finish_at(self.wallet.unlocked()?, vdf_output, timestamp_ms);
self.ledger.apply_locally_mined_block(block.clone())?;
self.outbox.push(GossipEnvelope::Block(block.clone()));
self.attest_pending_burns()?;
diff --git a/src/domain.rs b/src/domain.rs
@@ -1108,7 +1108,29 @@ impl PreparedBlock {
self.height
}
+ pub fn timestamp_ms(&self) -> u64 {
+ self.timestamp_ms
+ }
+
pub fn finish(self, wallet: &Wallet, vdf_output: String) -> Block {
+ let timestamp_ms = self.timestamp_ms;
+ self.finish_with_timestamp(wallet, vdf_output, timestamp_ms)
+ }
+
+ pub fn finish_at(self, wallet: &Wallet, vdf_output: String, timestamp_ms: u64) -> Block {
+ let timestamp_ms = match self.finalizer_mode {
+ FinalizerMode::Ticket => timestamp_ms.max(self.timestamp_ms),
+ FinalizerMode::Recovery => self.timestamp_ms,
+ };
+ self.finish_with_timestamp(wallet, vdf_output, timestamp_ms)
+ }
+
+ fn finish_with_timestamp(
+ self,
+ wallet: &Wallet,
+ vdf_output: String,
+ timestamp_ms: u64,
+ ) -> Block {
let leader_proof = self.leader_ticket.as_ref().map(|leader_ticket| {
let proof_payload = LeaderProofPayload {
height: self.height,
@@ -1124,7 +1146,7 @@ impl PreparedBlock {
Block::new(BlockDraft {
height: self.height,
prev_hash: self.prev_hash,
- timestamp_ms: self.timestamp_ms,
+ timestamp_ms,
miner: self.miner,
finalizer_mode: self.finalizer_mode,
finalizer_rank: self.finalizer_rank,
@@ -2097,7 +2119,7 @@ impl Ledger {
let tip = self.tip();
let prev_hash = tip.hash.clone();
- let timestamp_ms = timestamp_ms.max(tip.timestamp_ms + 1);
+ let timestamp_ms = timestamp_ms.max(ticket_block_min_timestamp(tip, finalizer_rank)?);
let vdf_seed = vdf_seed_for_child(&prev_hash, height);
Ok(PreparedBlock {
height,
@@ -2284,6 +2306,15 @@ impl Ledger {
if block.timestamp_ms <= self.tip().timestamp_ms {
bail!("block timestamp must increase");
}
+ if block.finalizer_mode == FinalizerMode::Ticket {
+ let min_timestamp = ticket_block_min_timestamp(self.tip(), block.finalizer_rank)?;
+ if block.timestamp_ms < min_timestamp {
+ bail!(
+ "block timestamp is before finalizer rank {} time slot {min_timestamp}",
+ block.finalizer_rank
+ );
+ }
+ }
let median_time_past = self.median_time_past();
if block.timestamp_ms <= median_time_past {
bail!("block timestamp must exceed median time past");
@@ -2960,6 +2991,26 @@ fn vdf_rounds_for_finalizer_rank(base_rounds: u64, rank: u32) -> Result<u64> {
Ok(rounds)
}
+fn finalizer_rank_slot_delay_ms(rank: u32) -> Result<u64> {
+ VDF_TARGET_BLOCK_MS
+ .checked_mul(u64::from(rank))
+ .context("finalizer rank time slot overflow")
+}
+
+fn ticket_block_min_timestamp(parent: &Block, rank: u32) -> Result<u64> {
+ if rank == 0 {
+ return parent
+ .timestamp_ms
+ .checked_add(1)
+ .context("finalizer rank minimum timestamp overflow");
+ }
+
+ parent
+ .timestamp_ms
+ .checked_add(finalizer_rank_slot_delay_ms(rank)?)
+ .context("finalizer rank minimum timestamp overflow")
+}
+
fn base_vdf_rounds_for_finalizer_rank(vdf_rounds: u64, rank: u32) -> u64 {
vdf_rounds / u64::from(rank.saturating_add(1).max(1))
}
@@ -3982,13 +4033,13 @@ fn clamped_vdf_retarget_observed_block_ms(observed_block_ms: u64) -> u64 {
}
fn vdf_retarget_observed_block_ms(parent: &Block, child: &Block) -> Option<u64> {
- if child.finalizer_mode == FinalizerMode::Recovery {
+ if child.finalizer_mode != FinalizerMode::Ticket || child.finalizer_rank != 0 {
return None;
}
- let rank_multiplier = u64::from(child.finalizer_rank) + 1;
- let rank_adjusted_ms = (child.timestamp_ms - parent.timestamp_ms) / rank_multiplier;
- Some(clamped_vdf_retarget_observed_block_ms(rank_adjusted_ms))
+ Some(clamped_vdf_retarget_observed_block_ms(
+ child.timestamp_ms - parent.timestamp_ms,
+ ))
}
fn unix_now_ms() -> u64 {
@@ -4526,20 +4577,26 @@ mod tests {
}
#[test]
- fn vdf_retarget_observed_block_time_scales_ticket_fallback_rank() {
+ fn vdf_retarget_observed_block_time_ignores_ticket_fallback_ranks() {
let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0);
+ let primary_child =
+ vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS, FinalizerMode::Ticket, 0);
let rank_one_child =
- vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS * 2, FinalizerMode::Ticket, 1);
+ vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS, FinalizerMode::Ticket, 1);
let rank_two_child =
- vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS * 3, FinalizerMode::Ticket, 2);
+ vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS * 2, FinalizerMode::Ticket, 2);
assert_eq!(
- vdf_retarget_observed_block_ms(&parent, &rank_one_child),
+ vdf_retarget_observed_block_ms(&parent, &primary_child),
Some(VDF_TARGET_BLOCK_MS)
);
assert_eq!(
+ vdf_retarget_observed_block_ms(&parent, &rank_one_child),
+ None
+ );
+ assert_eq!(
vdf_retarget_observed_block_ms(&parent, &rank_two_child),
- Some(VDF_TARGET_BLOCK_MS)
+ None
);
}
@@ -4583,8 +4640,8 @@ mod tests {
}
#[test]
- fn vdf_rounds_retarget_above_legacy_u32_limit_after_fast_blocks() {
- let wallet = Wallet::from_seed("vdf-rounds-above-u32");
+ fn vdf_rounds_retarget_below_legacy_u32_limit_after_slow_blocks() {
+ let wallet = Wallet::from_seed("vdf-rounds-slow-above-u32");
let initial_rounds = u64::from(u32::MAX);
let mut allocations = BTreeMap::new();
allocations.insert(wallet.address().to_string(), 1_000);
@@ -4599,18 +4656,21 @@ mod tests {
assert_eq!(block1.vdf_rounds, initial_rounds);
assert_eq!(ledger.vdf_rounds(), initial_rounds);
- let block2 =
- apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS + 415_000);
+ let block2 = apply_preverified_burn_block_at(
+ &mut ledger,
+ &wallet,
+ VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * 2,
+ );
assert_eq!(block2.vdf_rounds, initial_rounds);
assert!(
- ledger.vdf_rounds() > initial_rounds,
- "fast blocks should retarget above the legacy u32 VDF rounds ceiling"
+ ledger.vdf_rounds() < initial_rounds,
+ "slow blocks should retarget below the legacy u32 VDF rounds ceiling"
);
}
#[test]
- fn fallback_block_retarget_uses_rank_adjusted_observed_time() {
+ fn fallback_block_is_excluded_from_vdf_retarget_observations() {
let alice = Wallet::from_seed("fallback-retarget-alice");
let bob = Wallet::from_seed("fallback-retarget-bob");
let wallets = [&alice, &bob];
@@ -4640,11 +4700,8 @@ mod tests {
.into_iter()
.find(|wallet| wallet.address() != primary)
.unwrap();
- let block = apply_preverified_burn_block_at(
- &mut ledger,
- fallback,
- VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * 2,
- );
+ let timestamp_ms = ledger.tip().timestamp_ms + 1;
+ let block = apply_preverified_burn_block_at(&mut ledger, fallback, timestamp_ms);
assert_eq!(block.finalizer_rank, 1);
assert_eq!(block.vdf_rounds, 200);
@@ -4680,13 +4737,13 @@ mod tests {
}
#[test]
- fn generated_vdf_retarget_increases_after_fast_blocks_above_legacy_limit() {
+ fn generated_vdf_retarget_decreases_after_slow_blocks_above_legacy_limit() {
let legacy_limit = u64::from(u32::MAX);
- let fast_observed_ms = [
- MIN_VDF_RETARGET_OBSERVED_BLOCK_MS,
- 300_000,
- 415_000,
- VDF_TARGET_BLOCK_MS * 4 / 5,
+ let slow_observed_ms = [
+ VDF_TARGET_BLOCK_MS * 6 / 5,
+ VDF_TARGET_BLOCK_MS * 2,
+ VDF_TARGET_BLOCK_MS * 3,
+ MAX_VDF_RETARGET_OBSERVED_BLOCK_MS,
];
for seed in 0..16_u64 {
@@ -4700,7 +4757,7 @@ mod tests {
initial_rounds,
)
.unwrap();
- let observed_ms = fast_observed_ms[seed as usize % fast_observed_ms.len()];
+ let observed_ms = slow_observed_ms[seed as usize % slow_observed_ms.len()];
apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS);
let second = apply_preverified_burn_block_at(
@@ -4711,15 +4768,15 @@ mod tests {
assert_eq!(second.vdf_rounds, initial_rounds);
assert!(
- ledger.vdf_rounds() > initial_rounds,
- "seed {seed} with observed {observed_ms}ms should raise VDF rounds from {initial_rounds}, got {}",
+ ledger.vdf_rounds() < initial_rounds,
+ "seed {seed} with observed {observed_ms}ms should lower VDF rounds from {initial_rounds}, got {}",
ledger.vdf_rounds()
);
let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
ledger.submit_transaction(burn).unwrap();
let next_work = ledger
- .prepare_next_block(wallet.address(), VDF_TARGET_BLOCK_MS + observed_ms * 2)
+ .prepare_next_block(wallet.address(), second.timestamp_ms + VDF_TARGET_BLOCK_MS)
.unwrap();
assert_eq!(next_work.vdf_rounds(), ledger.vdf_rounds());
}
@@ -4742,6 +4799,129 @@ mod tests {
}
#[test]
+ fn ticket_block_timestamp_uses_finalizer_rank_time_slot() {
+ let alice = Wallet::from_seed("rank-slot-alice");
+ let bob = Wallet::from_seed("rank-slot-bob");
+ let wallets = [alice.clone(), bob.clone()];
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ allocations.insert(bob.address().to_string(), 1_000);
+ let mut ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![
+ GenesisBurn::new(alice.address(), 1),
+ GenesisBurn::new(bob.address(), 1),
+ ],
+ 100,
+ )
+ .unwrap();
+
+ let primary =
+ wallet_for_address(&wallets, &ledger.expected_leader_for_next_block().unwrap());
+ let burn = ledger.build_burn(primary, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let work = ledger.prepare_next_block(primary.address(), 1).unwrap();
+ assert_eq!(work.timestamp_ms(), 1);
+ let block = work.finish(primary, "preverified-vdf".to_string());
+ ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
+
+ let fallback = wallets
+ .iter()
+ .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1))
+ .expect("expected rank 1 fallback");
+ let burn = ledger.build_burn(fallback, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let parent_timestamp = ledger.tip().timestamp_ms;
+ let work = ledger
+ .prepare_next_block(fallback.address(), parent_timestamp + 1)
+ .unwrap();
+
+ assert_eq!(work.timestamp_ms(), parent_timestamp + VDF_TARGET_BLOCK_MS);
+ assert_eq!(work.vdf_rounds(), ledger.vdf_rounds() * 2);
+ }
+
+ #[test]
+ fn late_ticket_vdf_completion_is_visible_to_retarget() {
+ let wallet = Wallet::from_seed("late-ticket-vdf-wallet");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(wallet.address().to_string(), 1_000);
+ let mut ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![GenesisBurn::new(wallet.address(), 1)],
+ 100,
+ )
+ .unwrap();
+
+ apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS);
+ assert_eq!(ledger.vdf_rounds(), 100);
+
+ let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let work = ledger
+ .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1)
+ .unwrap();
+ let scheduled_timestamp = work.timestamp_ms();
+ let late_timestamp = ledger.tip().timestamp_ms + VDF_TARGET_BLOCK_MS * 3;
+ assert!(late_timestamp > scheduled_timestamp);
+
+ let block = work.finish_at(&wallet, "preverified-vdf".to_string(), late_timestamp);
+ assert_eq!(block.timestamp_ms, late_timestamp);
+ ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
+
+ assert!(
+ ledger.vdf_rounds() < 100,
+ "late VDF completion should lower future VDF rounds"
+ );
+ }
+
+ #[test]
+ fn block_before_finalizer_rank_time_slot_is_rejected() {
+ let alice = Wallet::from_seed("rank-slot-reject-alice");
+ let bob = Wallet::from_seed("rank-slot-reject-bob");
+ let wallets = [alice.clone(), bob.clone()];
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 1_000);
+ allocations.insert(bob.address().to_string(), 1_000);
+ let mut ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![
+ GenesisBurn::new(alice.address(), 1),
+ GenesisBurn::new(bob.address(), 1),
+ ],
+ 100,
+ )
+ .unwrap();
+
+ let primary =
+ wallet_for_address(&wallets, &ledger.expected_leader_for_next_block().unwrap());
+ let burn = ledger.build_burn(primary, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let work = ledger.prepare_next_block(primary.address(), 1).unwrap();
+ let block = work.finish(primary, "preverified-vdf".to_string());
+ ledger.apply_preverified_block_at(block, u64::MAX).unwrap();
+
+ let fallback = wallets
+ .iter()
+ .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1))
+ .expect("expected rank 1 fallback");
+ let burn = ledger.build_burn(fallback, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ let parent_timestamp = ledger.tip().timestamp_ms;
+ let work = ledger
+ .prepare_next_block(fallback.address(), parent_timestamp + 1)
+ .unwrap();
+ let mut block = work.finish(fallback, "preverified-vdf".to_string());
+ block.timestamp_ms = parent_timestamp + VDF_TARGET_BLOCK_MS - 1;
+ block.hash = block.compute_hash();
+
+ let error = ledger
+ .apply_preverified_block_at(block, u64::MAX)
+ .unwrap_err();
+
+ assert!(format!("{error:#}").contains("before finalizer rank 1 time slot"));
+ }
+
+ #[test]
fn miner_skips_oversized_pending_transaction_and_keeps_fitting_fee_transaction() {
let alice = Wallet::from_seed("oversized-select-alice");
let bob = Wallet::from_seed("oversized-select-bob");
diff --git a/src/main.rs b/src/main.rs
@@ -640,6 +640,7 @@ async fn run_automatic_finalizer(node: SharedNode, gossip: p2p::GossipNetwork, d
let seed = work.vdf_seed().to_string();
let rounds = work.vdf_rounds();
+ let publish_at_ms = work.timestamp_ms();
let vdf_output = match tokio::task::spawn_blocking(move || run_vdf(&seed, rounds)).await {
Ok(output) => output,
Err(error) => {
@@ -650,9 +651,23 @@ async fn run_automatic_finalizer(node: SharedNode, gossip: p2p::GossipNetwork, d
}
};
+ let completed_at_ms = now_ms();
+ let publish_timestamp_ms = completed_at_ms.max(publish_at_ms);
+ if completed_at_ms < publish_at_ms {
+ let wait_ms = publish_at_ms - completed_at_ms;
+ if debug {
+ println!(
+ "VDF completed early for candidate block {}; waiting {:.3}s for rank time slot",
+ work.height(),
+ wait_ms as f64 / 1000.0
+ );
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
+ }
+
let (finalized, outbox) = {
let mut node = node.lock().await;
- let finalized = node.complete_prepared_block(work, vdf_output);
+ let finalized = node.complete_prepared_block_at(work, vdf_output, publish_timestamp_ms);
let outbox = node.drain_outbox();
(finalized, outbox)
};
diff --git a/tests/iuna.rs b/tests/iuna.rs
@@ -51,6 +51,16 @@ fn unix_now_ms() -> u64 {
.unwrap_or(u64::MAX)
}
+fn next_ticket_slot_timestamp(ledger: &Ledger, offset_ms: u64) -> u64 {
+ ledger
+ .chain()
+ .last()
+ .expect("ledger has genesis")
+ .timestamp_ms
+ .saturating_add(VDF_TARGET_BLOCK_MS)
+ .saturating_add(offset_ms)
+}
+
fn mine_wallet_burn_block(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) -> String {
let burn = ledger.build_burn(wallet, 1, 0).unwrap();
ledger.submit_transaction(burn).unwrap();
@@ -96,7 +106,8 @@ fn fork_with_better_vrf_block(
) -> Option<Ledger> {
for offset in 0..10_000 {
let mut candidate = base.clone();
- let hash = mine_wallet_burn_block(&mut candidate, wallet, first_timestamp_ms + offset);
+ let timestamp_ms = next_ticket_slot_timestamp(&candidate, first_timestamp_ms + offset);
+ let hash = mine_wallet_burn_block(&mut candidate, wallet, timestamp_ms);
if hash.as_str() < local_fork_block_hash {
return Some(candidate);
}
@@ -112,7 +123,8 @@ fn fork_with_worse_vrf_block(
) -> Option<Ledger> {
for offset in 0..10_000 {
let mut candidate = base.clone();
- let hash = mine_wallet_burn_block(&mut candidate, wallet, first_timestamp_ms + offset);
+ let timestamp_ms = next_ticket_slot_timestamp(&candidate, first_timestamp_ms + offset);
+ let hash = mine_wallet_burn_block(&mut candidate, wallet, timestamp_ms);
if hash.as_str() > local_fork_block_hash {
return Some(candidate);
}
@@ -2396,12 +2408,14 @@ fn fork_conflict_before_last_six_blocks_is_finalized_even_if_remote_is_longer()
let mut local = common.clone();
for timestamp in 2..=8 {
- mine_wallet_burn_block(&mut local, &alice, timestamp);
+ let timestamp_ms = next_ticket_slot_timestamp(&local, timestamp);
+ mine_wallet_burn_block(&mut local, &alice, timestamp_ms);
}
let mut remote = common;
for timestamp in 20..=29 {
- mine_wallet_burn_block(&mut remote, &alice, timestamp);
+ let timestamp_ms = next_ticket_slot_timestamp(&remote, timestamp);
+ mine_wallet_burn_block(&mut remote, &alice, timestamp_ms);
}
assert_eq!(local.status().height, 8);
diff --git a/tests/properties.rs b/tests/properties.rs
@@ -453,9 +453,20 @@ fn finalize_preverified_with_wallet(ledger: &mut Ledger, wallet: &Wallet, timest
.expect("locally mined block applies");
}
+fn next_ticket_slot_timestamp(ledger: &Ledger, offset_ms: u64) -> u64 {
+ ledger
+ .chain()
+ .last()
+ .expect("ledger has genesis")
+ .timestamp_ms
+ .saturating_add(VDF_TARGET_BLOCK_MS)
+ .saturating_add(offset_ms)
+}
+
fn finalize_many(ledger: &mut Ledger, wallet: &Wallet, count: usize, start_timestamp_ms: u64) {
for offset in 0..count {
- finalize_with_wallet(ledger, wallet, start_timestamp_ms + offset as u64);
+ let timestamp_ms = next_ticket_slot_timestamp(ledger, start_timestamp_ms + offset as u64);
+ finalize_with_wallet(ledger, wallet, timestamp_ms);
}
}