commit aecdcb03e5f04bb500306f7ceb75705bbcedfd0d
parent d5c2ec9da29b51b29e11428642c6d9d54d3a3400
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sat, 29 Aug 2026 23:09:05 +0200
Add objective finality at height 1000
Diffstat:
19 files changed, 396 insertions(+), 51 deletions(-)
diff --git a/PLAN.md b/PLAN.md
@@ -111,9 +111,10 @@ Acceptance:
## 5. Eclipse And Partition Chaos
-Status: started with deterministic tests for delayed burn-bundle import across
-partitions, late burn gossip deduplication after reconnect, and rejecting a
-shorter attacker-only fork before recovering to a better majority tip.
+Status: objective finality activates at height `1000`; deterministic tests cover
+delayed burn-bundle import, late burn gossip deduplication, rejecting a shorter
+attacker-only fork, choosing a higher certified checkpoint even when its tip is
+shorter, and deterministic recovery from conflicting same-height certificates.
Goal: ensure isolated or stale nodes reject bad histories and recover cleanly.
@@ -127,7 +128,9 @@ Scenarios:
Acceptance:
- invalid chains are not adopted;
-- valid longer/better chains inside finality are adopted;
+- before height `1000`, valid longer/better chains inside legacy finality are adopted;
+- from height `1000`, the highest valid objective checkpoint is adopted without
+ a six-block recovery ceiling;
- old nodes catch up without manual database deletion.
## 6. Crash Consistency
diff --git a/ROADMAP.md b/ROADMAP.md
@@ -16,7 +16,7 @@ The current goal is to finalize release evidence and launch operations while pre
- [ ] Mainnet-candidate network has launched from a fresh genesis using release artifacts.
- [ ] New nodes can sync from genesis without manual intervention.
- [ ] Stale nodes can reconnect and catch up from old snapshots/range sync.
-- [ ] Network partitions heal according to fork choice.
+- [ ] Post-activation network partitions have an implemented objective checkpoint-based recovery rule; live soak evidence is still required to close this gate.
- [ ] Recovery blocks restore liveness when selected finalizers disappear.
- [ ] Multiple recovery candidates converge safely.
- [ ] Clock skew and future timestamp cases do not stall the network.
@@ -43,7 +43,7 @@ These items are not protocol rules. They are the attack and reliability checks t
- [x] Post-genesis transactions cannot spend with `genesis` input signatures.
- [x] P2P envelope item limits reject batches only above their configured boundaries.
- [x] Stratum endpoint has explicit DoS limits: maximum line size, maximum jobs per session, idle timeout, and connection/session caps.
-- [x] Fork and snapshot adversarial tests cover same-height leader-quality choice, taller valid forks inside finality, invalid late snapshot blocks, and pending transaction carry-forward after reorg.
+- [x] Fork and snapshot adversarial tests cover legacy finality, post-height-1000 objective checkpoints, deterministic conflicting-certificate recovery, same-height leader-quality choice, invalid late snapshot blocks, and pending transaction carry-forward after reorg.
- [x] Compact snapshot decoder has malformed-input tests for huge lengths, oversized varints, trailing bytes, truncated payloads, invalid tags, and random byte inputs without panics or excessive allocation.
- [x] Supply invariant tests cover mixed burns, fees, PoW mine actions, reorgs, no replay, and no double spend.
diff --git a/docs/operator-playbooks.md b/docs/operator-playbooks.md
@@ -101,10 +101,11 @@ Checks:
Recovery:
-- If the divergence is inside the finality window, keep nodes connected. The protocol can reorg to a taller valid fork or to a same-height fork with better leader quality.
+- Before height `1000`, if the divergence is inside the six-block legacy finality window, keep nodes connected. The protocol can reorg to a taller valid fork or to a same-height fork with better leader quality.
+- From height `1000`, compare `finalized_height` and `finalized_hash` in `/api/status` or `/api/network/health`. Keep nodes connected when one valid chain has a higher checkpoint: fork choice adopts it even if its tip is temporarily shorter.
- If your node is behind a healthy majority, add direct peers to that majority and let snapshot/range sync resolve it.
- If your node is alone on an old tip and does not converge, use Delete local chain in Settings. That clears local chain/UI data, keeps wallet and settings, and broadcasts a snapshot request to peers.
-- If multiple public nodes disagree beyond the finality window, stop automated restarts and preserve chain databases from both sides for analysis.
+- If multiple public nodes report conflicting hashes at the same finalized height, preserve both databases and signing evidence immediately. Nodes deterministically choose the lower checkpoint hash, so operators do not need to trust the first peer seen, but the conflict is a quorum safety incident and must be investigated before resuming release activity.
Avoid:
@@ -173,7 +174,9 @@ Avoid:
Height `1000` is a coordinated consensus activation. At that height, VDF seeds
start committing to block content and ticket draws stop using the final block
hash. Transaction signatures and native and Stratum mine proofs also switch to
-chain-bound binary format v1. This preserves blocks, snapshots, and UTXOs below
+chain-bound binary format v1. Rank `0` blocks also start requiring a strict
+two-thirds committee quorum so their next rank `0` child can objectively certify
+them. This preserves blocks, snapshots, and UTXOs below
`1000`, but nodes running the earlier rule will reject the upgraded chain or
build an incompatible fork at activation. No database reset, new genesis, or
migration command is needed for this height activation.
@@ -183,13 +186,18 @@ Before height `1000`:
1. Publish a tagged release, commit, checksums, and the activation height.
2. Upgrade every known public peer, finalizer, and bootstrap node.
3. Verify the reported package version on each managed node and compare tips.
-4. Stop or isolate nodes that cannot be upgraded before activation.
-5. Keep chain database backups from immediately before the activation window.
+4. Confirm every independent node agrees on the block hash at height `999`;
+ upgraded nodes freeze pre-activation history once they reach `1000`.
+5. Stop or isolate nodes that cannot be upgraded before activation.
+6. Keep chain database backups from immediately before the activation window.
At and after height `1000`, compare height and tip hash across at least three
-independent nodes. If upgraded nodes disagree, preserve both histories and stop
-automated restarts; do not reset the apparent majority until both forks have
-been validated.
+independent nodes. From height `1001`, also compare finalized height and hash.
+If one chain has the higher valid checkpoint, normal sync should converge to it.
+If equal-height checkpoint hashes conflict, preserve both histories and the
+committee signatures, stop automated restarts, and investigate the quorum
+failure; the deterministic lower-hash rule is the recovery decision and does
+not by itself make the safety breach harmless.
## No Burn Committee Signatures
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -28,7 +28,9 @@ The current mainnet-candidate parameter set is intentionally close to Bitcoin wh
- maximum orphan transactions per node: `1,024`;
- ticket maturity delay: `3` blocks;
- ticket expiry window: `3` block heights;
-- finality depth: `6` blocks;
+- legacy finality depth before height `1000`: `6` blocks;
+- objective finality activation height: `1000`;
+- objective finality quorum: strictly more than `2/3` of the selected committee;
- recovery delay: `6` target block times;
- future timestamp drift limit: `2 minutes`;
- VDF retarget window: `20` rank `0` ticket blocks;
@@ -49,7 +51,7 @@ The current mainnet-candidate parameter set is intentionally close to Bitcoin wh
Changing any value in this section requires a conscious mainnet-candidate reset or later hard-fork process.
-Transaction signing format v1 activates automatically at height `1000`. Existing chain state and history remain valid; operators only need to upgrade every consensus node before activation. A chain-ID or genesis change remains a separate consensus reset.
+Transaction signing format v1 and objective finality activate automatically at height `1000`. Existing chain state and history remain valid; operators only need to upgrade every consensus node before activation. A chain-ID or genesis change remains a separate consensus reset.
The consensus block-size limit is the exact number of bytes produced by the compact snapshot v6 block-body encoder when the block is appended to its parent chain. The encoder's reference tables are seeded by genesis allocations and extended in chain order, so all nodes calculate the same context-dependent size. The snapshot header, launch profile, block-count field, SQLite row metadata, and SQLite page overhead are not charged to an individual block.
@@ -363,11 +365,19 @@ Blocks are bounded by transaction count and exact compact stored block-body size
## Fork Choice
-Nodes fully validate candidate blocks or snapshots before considering a reorg. A candidate chain must share the same genesis and cannot rewrite history deeper than the finality depth. In the mainnet-candidate profile, forks whose common ancestor is below `local height - 6` are rejected.
+Nodes fully validate candidate blocks or snapshots before considering a reorg. A candidate chain must share the same genesis. Before height `1000`, the legacy rule rejects forks whose common ancestor is below `local height - 6`.
Burn inclusion is part of block validity. If a ticket block carries burn-list attestations but omits a burn required by those attestations, nodes reject the block before fork choice. The fork choice rule only compares chains made of valid blocks.
-Within that finality window, a taller valid candidate chain wins over the local chain. If the candidate and local chains have the same height but different tips, nodes compare the first divergent blocks by leader score: ticket blocks beat recovery blocks, lower finalizer rank beats higher rank, and the leader proof rank breaks remaining ties. Equal quality keeps the local chain.
+From block height `1000`, a rank `0` ticket block requires signatures from strictly more than two thirds of its selected burn committee. The leader counts as one signer through its leader proof; the other signers are the existing burn-bundle signatures. Every signature commits to the child height and parent hash. A valid rank `0` child above height `1000` therefore certifies its parent. For a five-slot committee this means the leader plus three explicit committee signatures. Smaller committees use `floor(2n/3) + 1` total signatures. Rank `1`, later ticket ranks, and recovery blocks retain their liveness thresholds but do not create an objective finality checkpoint.
+
+The first objective checkpoint is block `1000`, certified by a valid rank `0` block at height `1001`. Nodes reconstruct the highest checkpoint while replaying the existing compact snapshot; no block field, database migration, second genesis, or coin-state rewrite is required.
+
+For forks that first diverge at or after height `1000`, fork choice compares the highest valid checkpoint before chain length. A higher checkpoint wins even when its current tip is shorter, so healthy partitions can converge after more than six blocks. When checkpoints are identical, the taller chain wins and equal-height chains retain the existing first-divergent leader-score comparison. Conflicting certificates at the same height indicate a quorum safety failure; all nodes nevertheless recover deterministically to the lexicographically smaller checkpoint hash. A strictly higher conflicting certificate supersedes a lower checkpoint.
+
+This is **recoverable objective finality**, not an irreversible promise that a finalized block can never be reorganized. “Finalized” means that the selected committee for the next rank `0` block signed the block's hash as its parent with a strict two-thirds quorum, and that no competing chain has a better checkpoint under the public rule above. No node uses first-seen or first-peer trust to resolve a post-activation fork.
+
+An upgraded node that has reached height `1000` will not rewrite history below `1000`. Operators must therefore verify that the candidate network agrees on height `999` before activation. A node still below activation follows the legacy six-block rule while catching up; the candidate-to-mainnet promotion manifest can later pin a signed checkpoint without changing this ledger.
## Genesis and Joining
diff --git a/docs/security-review.md b/docs/security-review.md
@@ -10,7 +10,8 @@ until every launch-blocking item below is resolved or explicitly accepted.
Review:
-- block validation, fork choice, finality depth, recovery blocks, and VDF checks;
+- block validation, legacy finality depth, objective finality certificates and
+ fork choice, recovery blocks, and VDF checks;
- burn ticket eligibility, lineage limits, burn-list attestations, and bundle quorum;
- supply accounting across transfers, burns, fees, mine actions, and reorgs;
- genesis, snapshot adoption, and candidate-to-mainnet promotion rules.
@@ -28,6 +29,9 @@ Primary code:
Evidence already in the tree:
- adversarial consensus tests in `src/domain/adversarial_tests.rs`;
+- activation-boundary, quorum, higher-checkpoint, conflicting-certificate, and
+ pre-activation-history tests in `src/domain/ledger_reveal.rs` and
+ `src/domain/ledger_chain.rs`;
- release soak test in `tests/properties.rs`;
- protocol rules documented in `docs/protocol.md`;
- reset, joining, recovery, and rollback procedures in `docs/operator-playbooks.md`.
diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs
@@ -426,6 +426,8 @@ pub(super) async fn api_network_health(
NetworkHealthLocalState {
height: status.chain.height,
tip_hash: status.chain.tip_hash,
+ finalized_height: status.chain.finalized_height,
+ finalized_hash: status.chain.finalized_hash,
tip_timestamp_ms,
sync_start_height: sync_progress.map(|progress| progress.start_height),
sync_validated_height: sync_progress.map(|progress| progress.validated_height),
diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs
@@ -892,6 +892,8 @@ pub(super) const INDEX_HTML: &str = concat!(
<div class="network-health-grid">
<div class="peer-summary-item"><div class="peer-summary-label">Local Height</div><div class="peer-summary-value" x-text="networkHealth.local_height ?? '-'"></div></div>
<div class="peer-summary-item"><div class="peer-summary-label">Tip</div><code class="peer-summary-value" x-text="networkTipLabel()"></code></div>
+ <div class="peer-summary-item"><div class="peer-summary-label">Finalized</div><div class="peer-summary-value" x-text="networkHealth.finalized_height ?? '-'"></div></div>
+ <div class="peer-summary-item"><div class="peer-summary-label">Finalized Hash</div><code class="peer-summary-value" x-text="short(networkHealth.finalized_hash)"></code></div>
<div class="peer-summary-item"><div class="peer-summary-label">Last Block</div><div class="peer-summary-value" x-text="networkLastBlockAgeLabel()"></div></div>
<div class="peer-summary-item"><div class="peer-summary-label">Best Known</div><div class="peer-summary-value" x-text="networkHealth.best_known_height ?? '-'"></div></div>
<div class="peer-summary-item"><div class="peer-summary-label">Lag</div><div class="peer-summary-value" x-text="networkLagLabel()"></div></div>
diff --git a/src/adapters/http/metrics.rs b/src/adapters/http/metrics.rs
@@ -260,6 +260,8 @@ pub(super) fn network_health_at(
state,
local_height,
local_tip_hash: local.tip_hash,
+ finalized_height: local.finalized_height,
+ finalized_hash: local.finalized_hash,
last_block_age_ms,
best_known_height,
sync_start_height: local.sync_start_height,
@@ -362,6 +364,8 @@ mod tests {
let local = NetworkHealthLocalState {
height: 42,
tip_hash: "tip-hash".to_string(),
+ finalized_height: Some(40),
+ finalized_hash: Some("finalized-hash".to_string()),
tip_timestamp_ms: Some(1_000),
sync_start_height: Some(42),
sync_validated_height: Some(47),
@@ -397,6 +401,8 @@ mod tests {
assert_eq!(health.sync_target_height, Some(60));
assert_eq!(health.state, "syncing");
assert_eq!(health.local_tip_hash, "tip-hash");
+ assert_eq!(health.finalized_height, Some(40));
+ assert_eq!(health.finalized_hash.as_deref(), Some("finalized-hash"));
assert_eq!(health.last_block_age_ms, Some(1_500));
assert_eq!(health.last_finalizer_mode.as_deref(), Some("ticket"));
assert_eq!(health.last_finalizer_rank, Some(1));
diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs
@@ -28,6 +28,8 @@ pub(super) struct NetworkHealthResponse {
pub(super) state: String,
pub(super) local_height: u64,
pub(super) local_tip_hash: String,
+ pub(super) finalized_height: Option<u64>,
+ pub(super) finalized_hash: Option<String>,
pub(super) last_block_age_ms: Option<u64>,
pub(super) best_known_height: u64,
pub(super) sync_start_height: Option<u64>,
@@ -76,6 +78,8 @@ impl MempoolCounts {
pub(super) struct NetworkHealthLocalState {
pub(super) height: u64,
pub(super) tip_hash: String,
+ pub(super) finalized_height: Option<u64>,
+ pub(super) finalized_hash: Option<String>,
pub(super) tip_timestamp_ms: Option<u64>,
pub(super) sync_start_height: Option<u64>,
pub(super) sync_validated_height: Option<u64>,
diff --git a/src/domain.rs b/src/domain.rs
@@ -36,7 +36,7 @@ use block::LeaderProofPayload;
pub use block::{
Block, BurnLeaderRank, ChainSnapshot, ChainStatus, FinalizerMode, LeaderProof, PreparedBlock,
};
-use fork::LeaderScore;
+use fork::{FinalityCheckpoint, LeaderScore};
pub(crate) use genesis::genesis_allocation_outpoint;
pub use hex::hex_hash;
use hex::{decode_hex, decode_hex_array, hex_encode};
@@ -61,8 +61,8 @@ pub use protocol::{
DEFAULT_MINE_FEE, DEFAULT_TRANSACTION_FEE, GRINDING_RESISTANCE_ACTIVATION_HEIGHT,
MAX_BLOCK_BYTES, MAX_BURN_BUNDLE_BYTES, MAX_PENDING_TRANSACTIONS, MAX_VDF_ROUNDS, MICRO_IUNA,
MINE_ACTIONS_PER_ANCHOR_LIMIT, MINE_DIFFICULTY_BITS, MINE_FINALIZER_FEE, MINE_REWARD,
- RECOVERY_BLOCK_DELAY_MS, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, TransactionSubmitOutcome,
- VDF_TARGET_BLOCK_MS,
+ OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, RECOVERY_BLOCK_DELAY_MS,
+ TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS,
};
use protocol::{
BLOCK_MEDIAN_TIME_PAST_WINDOW, DEFAULT_TICKET_EXPIRY_WINDOW, DEFAULT_TICKET_MATURITY_DELAY,
diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs
@@ -13,9 +13,10 @@ use super::{
Amount, BURN_COMMITTEE_SIZE, BURN_LINEAGE_MATURITY_HEIGHTS, Block, BurnBundle,
BurnBundleSignature, BurnCommitteeMember, BurnLeaderRank, ChainSnapshot, FinalizerMode,
GRINDING_RESISTANCE_ACTIVATION_HEIGHT, GenesisBurn, LeaderProofPayload, Ledger,
- MAX_BLOCK_BYTES, MAX_BURN_BUNDLE_BYTES, MICRO_IUNA, MaskedBurn, OutPoint, Transaction,
- TransactionSubmitOutcome, TxOutput, UtxoLineageRoot, VDF_TARGET_BLOCK_MS, Wallet,
- genesis_allocation_outpoint, hex_hash, reward_outputs_for_block, run_vdf,
+ MAX_BLOCK_BYTES, MAX_BURN_BUNDLE_BYTES, MICRO_IUNA, MaskedBurn,
+ OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, OutPoint, Transaction, TransactionSubmitOutcome,
+ TxOutput, UtxoLineageRoot, VDF_TARGET_BLOCK_MS, Wallet, genesis_allocation_outpoint, hex_hash,
+ reward_outputs_for_block, run_vdf,
};
const NOW_MS: u64 = 10_000_000_000;
@@ -1880,6 +1881,7 @@ fn mini_validate_burn_bundle_section(ledger: &Ledger, block: &Block) -> Option<(
}
let required_signatures = mini_required_explicit_burn_signatures(
+ block.height,
block.finalizer_mode,
block.finalizer_rank,
committee.len(),
@@ -1954,6 +1956,7 @@ fn mini_validate_burn_bundle_section(ledger: &Ledger, block: &Block) -> Option<(
}
fn mini_required_explicit_burn_signatures(
+ height: u64,
finalizer_mode: FinalizerMode,
finalizer_rank: u32,
committee_size: usize,
@@ -1962,6 +1965,11 @@ fn mini_required_explicit_burn_signatures(
return 0;
}
match finalizer_mode {
+ FinalizerMode::Ticket
+ if finalizer_rank == 0 && height >= OBJECTIVE_FINALITY_ACTIVATION_HEIGHT =>
+ {
+ committee_size.saturating_mul(2) / 3
+ }
FinalizerMode::Ticket if finalizer_rank == 0 => committee_size.min(3).saturating_sub(1),
FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.min(2).saturating_sub(1),
FinalizerMode::Ticket | FinalizerMode::Recovery => 0,
@@ -2002,9 +2010,31 @@ fn mini_choose_fork(local: &Ledger, candidate: &Ledger) -> Option<bool> {
return Some(false);
}
- let finalized_floor = local.height().saturating_sub(super::FORK_FINALITY_DEPTH);
- if common_ancestor_height < finalized_floor {
- return Some(false);
+ let first_diverging_height = common_ancestor_height.saturating_add(1);
+ if first_diverging_height < OBJECTIVE_FINALITY_ACTIVATION_HEIGHT {
+ let finalized_floor = local.height().saturating_sub(super::FORK_FINALITY_DEPTH);
+ if local.height() >= OBJECTIVE_FINALITY_ACTIVATION_HEIGHT
+ || common_ancestor_height < finalized_floor
+ {
+ return Some(false);
+ }
+ } else {
+ match (
+ local.objective_finality_checkpoint(),
+ candidate.objective_finality_checkpoint(),
+ ) {
+ (None, Some(_)) => return Some(true),
+ (Some(_), None) => return Some(false),
+ (Some((local_height, local_hash)), Some((remote_height, remote_hash))) => {
+ if remote_height != local_height {
+ return Some(remote_height > local_height);
+ }
+ if remote_hash != local_hash {
+ return Some(remote_hash < local_hash);
+ }
+ }
+ (None, None) => {}
+ }
}
if candidate.height() > local.height() {
return Some(true);
diff --git a/src/domain/block.rs b/src/domain/block.rs
@@ -319,6 +319,8 @@ impl PreparedBlock {
pub struct ChainStatus {
pub height: u64,
pub tip_hash: String,
+ pub finalized_height: Option<u64>,
+ pub finalized_hash: Option<String>,
pub next_leader: Option<String>,
pub launch_profile_hash: String,
pub mine_reward: Amount,
diff --git a/src/domain/fork.rs b/src/domain/fork.rs
@@ -3,6 +3,12 @@ pub(super) struct ForkPoint {
pub(super) common_ancestor_height: u64,
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(super) struct FinalityCheckpoint {
+ pub(super) height: u64,
+ pub(super) hash: String,
+}
+
impl ForkPoint {
pub(super) fn first_diverging_height(self) -> u64 {
self.common_ancestor_height + 1
diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs
@@ -13,9 +13,10 @@ use super::ticket::{
};
use super::transaction::transaction_inputs_available;
use super::{
- Amount, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block, BurnBundleSection, FinalizerMode, Ledger,
- MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, Transaction, insert_output_with_lineage,
- output_lineage_root_for_transaction, spend_inputs_with_lineage, unix_now_ms, verify_vdf,
+ Amount, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block, BurnBundleSection, FinalityCheckpoint,
+ FinalizerMode, Ledger, MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, Transaction,
+ insert_output_with_lineage, output_lineage_root_for_transaction, spend_inputs_with_lineage,
+ unix_now_ms, verify_vdf,
};
impl Ledger {
@@ -70,6 +71,12 @@ impl Ledger {
}
let reward_committee = self.burn_committee_for_block(&block);
+ let certified_parent = self
+ .block_certifies_parent(&block, reward_committee.len())
+ .then(|| FinalityCheckpoint {
+ height: self.tip().height,
+ hash: self.tip().hash.clone(),
+ });
let mut utxos = self.utxos.clone();
let mut utxo_lineage = self.utxo_lineage.clone();
let mut lineage_values = self.lineage_values.clone();
@@ -111,6 +118,9 @@ impl Ledger {
self.lineage_owners = lineage_owners;
self.tickets = tickets;
self.chain.push(block);
+ if let Some(checkpoint) = certified_parent {
+ self.objective_finality_checkpoint = Some(checkpoint);
+ }
let next_signing_domain = self.transaction_signing_domain();
let available = self.utxos.clone();
let pending = std::mem::take(&mut self.pending);
diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs
@@ -4,13 +4,13 @@ use anyhow::{Result, bail};
use crate::compact::CompactBlockContext;
-use super::fork::{ForkChoice, ForkPoint, ForkQuality};
+use super::fork::{FinalityCheckpoint, ForkChoice, ForkPoint, ForkQuality};
use super::genesis::{build_genesis_block, utxos_after_genesis, validate_genesis_block};
use super::ledger_ops::validate_genesis_allocations;
use super::ticket::genesis_tickets;
use super::{
- Amount, ChainSnapshot, GenesisBurn, LaunchProfile, Ledger, MINE_REWARD, Transaction,
- unix_now_ms,
+ Amount, ChainSnapshot, GenesisBurn, LaunchProfile, Ledger, MINE_REWARD,
+ OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, Transaction, unix_now_ms,
};
impl Ledger {
@@ -93,6 +93,7 @@ impl Ledger {
vdf_rounds,
launch_profile,
compact_block_context,
+ objective_finality_checkpoint: None,
})
}
@@ -157,6 +158,7 @@ impl Ledger {
vdf_rounds,
launch_profile,
compact_block_context,
+ objective_finality_checkpoint: None,
};
ledger.tickets = genesis_tickets(
&ledger.genesis_allocations,
@@ -258,8 +260,21 @@ impl Ledger {
return ForkChoice::KeepLocal;
}
- if fork_rewrites_finalized_history(local_height, fork_point.common_ancestor_height) {
- return ForkChoice::KeepLocal;
+ if fork_point.first_diverging_height() < OBJECTIVE_FINALITY_ACTIVATION_HEIGHT {
+ if local_height >= OBJECTIVE_FINALITY_ACTIVATION_HEIGHT
+ || fork_rewrites_finalized_history(local_height, fork_point.common_ancestor_height)
+ {
+ return ForkChoice::KeepLocal;
+ }
+ } else {
+ match objective_finality_quality(
+ self.objective_finality_checkpoint.as_ref(),
+ candidate.objective_finality_checkpoint.as_ref(),
+ ) {
+ ForkQuality::RemoteBetter => return ForkChoice::SwitchToCandidate,
+ ForkQuality::LocalBetter => return ForkChoice::KeepLocal,
+ ForkQuality::Equal => {}
+ }
}
if remote_height > local_height {
@@ -320,6 +335,26 @@ impl Ledger {
}
}
+fn objective_finality_quality(
+ local: Option<&FinalityCheckpoint>,
+ remote: Option<&FinalityCheckpoint>,
+) -> ForkQuality {
+ match (local, remote) {
+ (None, None) => ForkQuality::Equal,
+ (None, Some(_)) => ForkQuality::RemoteBetter,
+ (Some(_), None) => ForkQuality::LocalBetter,
+ (Some(local), Some(remote)) => match local.height.cmp(&remote.height) {
+ std::cmp::Ordering::Less => ForkQuality::RemoteBetter,
+ std::cmp::Ordering::Greater => ForkQuality::LocalBetter,
+ std::cmp::Ordering::Equal if local.hash == remote.hash => ForkQuality::Equal,
+ // A conflicting certificate is a safety failure. The canonical hash ordering
+ // nevertheless gives every honest node the same recovery decision.
+ std::cmp::Ordering::Equal if remote.hash < local.hash => ForkQuality::RemoteBetter,
+ std::cmp::Ordering::Equal => ForkQuality::LocalBetter,
+ },
+ }
+}
+
fn fork_rewrites_finalized_history(local_height: u64, common_ancestor_height: u64) -> bool {
let finalized_floor = local_height.saturating_sub(super::FORK_FINALITY_DEPTH);
common_ancestor_height < finalized_floor
@@ -329,9 +364,12 @@ fn fork_rewrites_finalized_history(local_height: u64, common_ancestor_height: u6
mod tests {
use std::collections::BTreeMap;
- use super::fork_rewrites_finalized_history;
+ use super::{
+ ForkChoice, ForkPoint, ForkQuality, fork_rewrites_finalized_history,
+ objective_finality_quality,
+ };
use crate::domain::{
- FORK_FINALITY_DEPTH, LaunchProfile, Ledger, StratumMineShare,
+ FORK_FINALITY_DEPTH, FinalityCheckpoint, LaunchProfile, Ledger, StratumMineShare,
TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, Transaction, Wallet,
};
@@ -361,6 +399,134 @@ mod tests {
}
#[test]
+ fn higher_objective_checkpoint_wins_even_when_candidate_is_shorter() {
+ let mut local = Ledger::new(BTreeMap::new(), 1);
+ let mut candidate = local.clone();
+ local.chain.last_mut().unwrap().height = 1_012;
+ local.chain.last_mut().unwrap().hash = "local-tip".to_string();
+ local.objective_finality_checkpoint = Some(FinalityCheckpoint {
+ height: 1_004,
+ hash: "local-finalized".to_string(),
+ });
+ candidate.chain.last_mut().unwrap().height = 1_008;
+ candidate.chain.last_mut().unwrap().hash = "remote-tip".to_string();
+ candidate.objective_finality_checkpoint = Some(FinalityCheckpoint {
+ height: 1_007,
+ hash: "remote-finalized".to_string(),
+ });
+
+ assert_eq!(
+ local.choose_fork(
+ &candidate,
+ ForkPoint {
+ common_ancestor_height: 1_000,
+ },
+ ),
+ ForkChoice::SwitchToCandidate
+ );
+ }
+
+ #[test]
+ fn conflicting_same_height_certificates_have_deterministic_hash_tie_break() {
+ let local = FinalityCheckpoint {
+ height: 1_010,
+ hash: "bbbb".to_string(),
+ };
+ let remote = FinalityCheckpoint {
+ height: 1_010,
+ hash: "aaaa".to_string(),
+ };
+
+ assert_eq!(
+ objective_finality_quality(Some(&local), Some(&remote)),
+ ForkQuality::RemoteBetter
+ );
+ assert_eq!(
+ objective_finality_quality(Some(&remote), Some(&local)),
+ ForkQuality::LocalBetter
+ );
+ }
+
+ #[test]
+ fn objective_finality_partition_model_is_total_antisymmetric_and_transitive() {
+ let checkpoints = [
+ None,
+ Some(FinalityCheckpoint {
+ height: 1_000,
+ hash: "aaaa".to_string(),
+ }),
+ Some(FinalityCheckpoint {
+ height: 1_000,
+ hash: "bbbb".to_string(),
+ }),
+ Some(FinalityCheckpoint {
+ height: 1_001,
+ hash: "aaaa".to_string(),
+ }),
+ Some(FinalityCheckpoint {
+ height: 1_020,
+ hash: "cccc".to_string(),
+ }),
+ ];
+
+ for local in &checkpoints {
+ for remote in &checkpoints {
+ let forward = objective_finality_quality(local.as_ref(), remote.as_ref());
+ let reverse = objective_finality_quality(remote.as_ref(), local.as_ref());
+ assert!(matches!(
+ (forward, reverse),
+ (ForkQuality::Equal, ForkQuality::Equal)
+ | (ForkQuality::LocalBetter, ForkQuality::RemoteBetter)
+ | (ForkQuality::RemoteBetter, ForkQuality::LocalBetter)
+ ));
+ }
+ }
+
+ for first in &checkpoints {
+ for second in &checkpoints {
+ for third in &checkpoints {
+ let first_beats_second =
+ objective_finality_quality(first.as_ref(), second.as_ref())
+ == ForkQuality::LocalBetter;
+ let second_beats_third =
+ objective_finality_quality(second.as_ref(), third.as_ref())
+ == ForkQuality::LocalBetter;
+ if first_beats_second && second_beats_third {
+ assert_eq!(
+ objective_finality_quality(first.as_ref(), third.as_ref()),
+ ForkQuality::LocalBetter
+ );
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn activated_node_freezes_history_before_height_1000() {
+ let mut local = Ledger::new(BTreeMap::new(), 1);
+ let mut candidate = local.clone();
+ local.chain.last_mut().unwrap().height = 1_010;
+ local.chain.last_mut().unwrap().hash = "local-tip".to_string();
+ candidate.chain.last_mut().unwrap().height = 1_020;
+ candidate.chain.last_mut().unwrap().hash = "remote-tip".to_string();
+ candidate.objective_finality_checkpoint = Some(FinalityCheckpoint {
+ height: 1_019,
+ hash: "remote-finalized".to_string(),
+ });
+
+ assert_eq!(
+ local.choose_fork(
+ &candidate,
+ ForkPoint {
+ common_ancestor_height: 998,
+ },
+ ),
+ ForkChoice::KeepLocal
+ );
+ }
+
+ #[test]
fn transfer_and_burn_signatures_cannot_replay_between_chain_ids() {
let alice = Wallet::from_seed("chain-replay-alice");
let bob = Wallet::from_seed("chain-replay-bob");
diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs
@@ -99,6 +99,14 @@ impl Ledger {
ChainStatus {
height: self.tip().height,
tip_hash: self.tip().hash.clone(),
+ finalized_height: self
+ .objective_finality_checkpoint
+ .as_ref()
+ .map(|checkpoint| checkpoint.height),
+ finalized_hash: self
+ .objective_finality_checkpoint
+ .as_ref()
+ .map(|checkpoint| checkpoint.hash.clone()),
next_leader: self.expected_leader_for_next_block(),
launch_profile_hash: self.launch_profile.hash(),
mine_reward: self.mine_reward,
@@ -114,6 +122,12 @@ impl Ledger {
&self.tip().hash
}
+ pub fn objective_finality_checkpoint(&self) -> Option<(u64, &str)> {
+ self.objective_finality_checkpoint
+ .as_ref()
+ .map(|checkpoint| (checkpoint.height, checkpoint.hash.as_str()))
+ }
+
pub fn chain(&self) -> &[Block] {
&self.chain
}
diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs
@@ -7,7 +7,7 @@ use super::reveal::{burn_bundle_slot_mask, burn_committee_mask};
use super::{
Amount, BURN_COMMITTEE_SIZE, Block, BurnBundle, BurnBundlePayload, BurnBundleSection,
BurnBundleSignature, BurnCommitteeMember, FinalizerMode, Ledger, MAX_BURN_BUNDLE_BYTES,
- MaskedBurn, Transaction, Wallet,
+ MaskedBurn, OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, Transaction, Wallet,
};
impl Ledger {
@@ -27,7 +27,12 @@ impl Ledger {
.len(),
FinalizerMode::Recovery => 1,
};
- self.required_explicit_burn_signatures(finalizer_mode, finalizer_rank, committee_size)
+ self.required_explicit_burn_signatures(
+ self.height() + 1,
+ finalizer_mode,
+ finalizer_rank,
+ committee_size,
+ )
}
pub fn build_burn_bundle(&self, wallet: &Wallet) -> Result<Option<BurnBundle>> {
@@ -245,6 +250,7 @@ impl Ledger {
included_mask |= burn_bundle_slot_mask(signature.slot)?;
}
let required_signatures = self.required_explicit_burn_signatures(
+ block.height,
block.finalizer_mode,
block.finalizer_rank,
committee.len(),
@@ -300,6 +306,7 @@ impl Ledger {
fn required_explicit_burn_signatures(
&self,
+ height: u64,
finalizer_mode: FinalizerMode,
finalizer_rank: u32,
committee_size: usize,
@@ -308,6 +315,11 @@ impl Ledger {
return 0;
}
match finalizer_mode {
+ FinalizerMode::Ticket
+ if finalizer_rank == 0 && height >= OBJECTIVE_FINALITY_ACTIVATION_HEIGHT =>
+ {
+ objective_finality_quorum(committee_size).saturating_sub(1)
+ }
FinalizerMode::Ticket if finalizer_rank == 0 => committee_size.min(3).saturating_sub(1),
FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.min(2).saturating_sub(1),
FinalizerMode::Ticket => 0,
@@ -315,6 +327,15 @@ impl Ledger {
}
}
+ pub(super) fn block_certifies_parent(&self, block: &Block, committee_size: usize) -> bool {
+ block.height > OBJECTIVE_FINALITY_ACTIVATION_HEIGHT
+ && block.finalizer_mode == FinalizerMode::Ticket
+ && block.finalizer_rank == 0
+ && committee_size > 0
+ && block.burn_bundle_section.signatures.len() + 1
+ >= objective_finality_quorum(committee_size)
+ }
+
fn validate_burn_bundles_for_any_next_ticket_block(
&self,
expected_height: u64,
@@ -435,6 +456,14 @@ impl Ledger {
}
}
+pub(super) fn objective_finality_quorum(committee_size: usize) -> usize {
+ if committee_size == 0 {
+ 0
+ } else {
+ committee_size.saturating_mul(2) / 3 + 1
+ }
+}
+
fn matching_burn_by_signature<'a>(
attested: &Transaction,
transactions: &'a [Transaction],
@@ -494,44 +523,90 @@ mod tests {
let ledger = ledger();
assert_eq!(
- ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 5),
+ ledger.required_explicit_burn_signatures(999, FinalizerMode::Ticket, 0, 5),
2
);
assert_eq!(
- ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 4),
+ ledger.required_explicit_burn_signatures(999, FinalizerMode::Ticket, 0, 4),
2
);
assert_eq!(
- ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 3),
+ ledger.required_explicit_burn_signatures(999, FinalizerMode::Ticket, 0, 3),
2
);
assert_eq!(
- ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 2),
+ ledger.required_explicit_burn_signatures(999, FinalizerMode::Ticket, 0, 2),
1
);
assert_eq!(
- ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 1, 5),
+ ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 0, 5),
+ 3
+ );
+ assert_eq!(
+ ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 0, 4),
+ 2
+ );
+ assert_eq!(
+ ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 0, 3),
+ 2
+ );
+ assert_eq!(
+ ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 1, 5),
1
);
assert_eq!(
- ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 1, 2),
+ ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 1, 2),
1
);
assert_eq!(
- ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 2, 3),
+ ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 2, 3),
0
);
assert_eq!(
- ledger.required_explicit_burn_signatures(FinalizerMode::Recovery, 0, 3),
+ ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Recovery, 0, 3),
0
);
assert_eq!(
- ledger.required_explicit_burn_signatures(FinalizerMode::Ticket, 0, 1),
+ ledger.required_explicit_burn_signatures(1_000, FinalizerMode::Ticket, 0, 1),
0
);
}
#[test]
+ fn only_post_activation_rank_zero_quorum_certifies_its_parent() {
+ let ledger = ledger();
+ let mut block = ledger.tip().clone();
+ block.height = 1_001;
+ block.finalizer_mode = FinalizerMode::Ticket;
+ block.finalizer_rank = 0;
+ block.burn_bundle_section.signatures = (1..=3)
+ .map(|slot| BurnBundleSignature {
+ slot,
+ member: format!("member-{slot}"),
+ signature: format!("signature-{slot}"),
+ })
+ .collect();
+
+ assert!(ledger.block_certifies_parent(&block, 5));
+
+ block.height = OBJECTIVE_FINALITY_ACTIVATION_HEIGHT;
+ assert!(!ledger.block_certifies_parent(&block, 5));
+ block.height += 1;
+ block.burn_bundle_section.signatures.pop();
+ assert!(!ledger.block_certifies_parent(&block, 5));
+ block
+ .burn_bundle_section
+ .signatures
+ .push(BurnBundleSignature {
+ slot: 3,
+ member: "member-3".to_string(),
+ signature: "signature-3".to_string(),
+ });
+ block.finalizer_rank = 1;
+ assert!(!ledger.block_certifies_parent(&block, 5));
+ }
+
+ #[test]
fn burn_bundle_section_deduplicates_burns_and_tracks_member_masks() {
let ledger = ledger();
let high_fee_burn = burn("a", 10);
diff --git a/src/domain/ledger_state.rs b/src/domain/ledger_state.rs
@@ -4,8 +4,8 @@ use std::{
};
use super::{
- Amount, Block, BurnTicket, LaunchProfile, LineageOwnerValues, OutPoint, Transaction, TxOutput,
- UtxoLineageRoot,
+ Amount, Block, BurnTicket, FinalityCheckpoint, LaunchProfile, LineageOwnerValues, OutPoint,
+ Transaction, TxOutput, UtxoLineageRoot,
};
use crate::compact::CompactBlockContext;
@@ -27,6 +27,7 @@ pub struct Ledger {
pub(super) vdf_rounds: u64,
pub(super) launch_profile: LaunchProfile,
pub(super) compact_block_context: CompactBlockContext,
+ pub(super) objective_finality_checkpoint: Option<FinalityCheckpoint>,
}
pub(super) fn unix_now_ms() -> u64 {
diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs
@@ -18,6 +18,7 @@ pub const MAX_BURN_BUNDLE_BYTES: usize = 10_000;
pub const BURN_LINEAGE_MATURITY_HEIGHTS: u64 = 20;
pub const GRINDING_RESISTANCE_ACTIVATION_HEIGHT: u64 = 1_000;
pub const TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT: u64 = 1_000;
+pub const OBJECTIVE_FINALITY_ACTIVATION_HEIGHT: u64 = 1_000;
pub const MAX_PENDING_TRANSACTIONS: usize = 10_000;
pub(super) const MAX_PENDING_POOL_BYTES: usize = 8 * 1024 * 1024;
@@ -65,6 +66,7 @@ mod tests {
assert_eq!(BURN_LINEAGE_MATURITY_HEIGHTS, 20);
assert_eq!(GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 1_000);
assert_eq!(TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, 1_000);
+ assert_eq!(OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, 1_000);
assert_eq!(MAX_PENDING_TRANSACTIONS, 10_000);
assert_eq!(MAX_PENDING_POOL_BYTES, 8 * 1024 * 1024);
assert_eq!(MAX_ORPHAN_TRANSACTIONS, 1_024);