Skip to content

Finalization Flow

This document traces the complete block finalization flow including BLS aggregation, threshold signatures, and light client verification.

Block finalization uses threshold BLS signatures - each validator signs with their DKG share, and 2f+1 signatures are aggregated into a single compact certificate. Light clients can verify finality using only the aggregate public key.

Block proposed --> Validators vote (BLS shares) --> Aggregate 2f+1 sigs -->
Finality certificate --> Persist --> Light client verifies
Simplex Consensus (BFT voting)
|
Marshal (certificate persistence)
|
Application Actor (block execution, finality proofs)
|
Ashen Store (persistent finalized blocks)

async fn handle_propose_request(&mut self, round: Round) {
// 1. Get parent block
let parent = self.tip.as_ref().unwrap_or(&genesis);
// 2. Select transactions from txpool
let txs = self.txpool.select_block_txs(max_txs, max_gas);
// 3. Load parent VRF output for randomness
let parent_vrf = if let Some(proof) = self.load_finality_proof(parent_height) {
proof.vrf_output()
} else {
Hash::default()
};
// 4. Build execution context
let ctx = ExecutionContext {
timestamp,
validator_set_id,
epoch,
view,
parent_vrf_output: parent_vrf,
proposer: self.address,
};
// 5. Execute and compute state root
let (header, _) = self.exec.build_block_preview(parent, &txs, ctx)?;
// 6. Cache block (Arc for efficiency)
let block = Arc::new(Block { header, execution });
self.blocks_by_hash.insert(block_hash, block.clone());
self.pending_proposals.put(round, block);
// 7. Send hash to Simplex
simplex_mailbox.propose(block_hash).await;
}

Validators vote on proposed blocks using their BLS shares from DKG.

pub type PublicKey = ed25519::PublicKey; // Network identity
pub type Scheme = bls12381_threshold::Scheme<PublicKey, MinSig>;
pub type Evaluation = <MinSig as Variant>::Public; // G2 element

MinSig Variant (signature minimization):

  • Public key: G2 element (~96 bytes)
  • Signature: G1 element (~48 bytes)
  • Aggregation: G1 point addition

Each validator produces a partial BLS signature using their DKG share:

// Validator i signs with their share
let partial_sig = ops::sign_message::<MinSig>(
&my_share.secret, // Scalar from DKG
Some(FINALIZE_DOMAIN),
&proposal_hash,
);
// Result: G1 element

When 2f+1 votes are collected:

// Aggregate via G1 point addition
let aggregate_sig = partial_sigs
.iter()
.fold(G1Projective::identity(), |acc, sig| acc + sig);
// Result: Single G1 element representing all votes

pub struct FinalityProof {
pub header: BlockHeader, // Finalized block
pub epoch: u64, // Consensus epoch
pub view: u64, // Round in epoch
pub parent_view: u64, // Parent's round
pub key_version: u32, // Validator set version
pub certificate: FinalityCertificateBytes, // Threshold BLS sig
}
pub struct FinalityCertificateBytes {
pub bytes: Vec<u8>, // Borsh-encoded SimplexFinalization
}

The certificate encodes a SimplexFinalization:

SimplexFinalization {
proposal: Proposal {
round: Round { epoch, view },
parent_view: View,
payload: block_hash, // SHA256 digest
},
certificate: ThresholdSignature, // Aggregated G1 point
}
pub fn vrf_output(&self) -> Hash {
let mut hasher = Blake3::new();
hasher.update(&self.certificate.bytes);
Hash::from(hasher.finalize())
}

Properties:

  • Unpredictable until 2f+1 validators sign
  • Deterministic given the certificate
  • Used for on-chain randomness, leader election

pub struct AggregateBlsPublicKey(pub G2);

The aggregate key is the constant term of the DKG polynomial:

// From DKG output
let aggregate_key = polynomial.evaluate(0); // G2 element
// Create validator set with aggregate key
let validator_set = ValidatorSet {
aggregate_bls_pubkey: AggregateBlsPublicKey(aggregate_key),
validators: vec![...],
};
pub struct ValidatorSet {
pub aggregate_bls_pubkey: AggregateBlsPublicKey,
pub validators: Vec<Validator>,
}
pub struct Validator {
pub network_pubkey: NetworkPublicKey, // ed25519
pub voting_power: u64,
pub address: Address,
pub fee_recipient: Address,
}

The validator set is committed via a Merkle-like tree:

pub fn commitment_root(&self) -> Hash {
let mut leaves = Vec::with_capacity(self.validators.len() + 1);
// Leaf 0: aggregate BLS key
leaves.push(self.aggregate_key_leaf_hash());
// Leaves 1..n: individual validators
for i in 0..self.validators.len() {
leaves.push(self.validator_leaf_hash(i)?);
}
merkle_root(&leaves)
}
pub fn aggregate_key_leaf_hash(&self) -> Hash {
let preimage = AggregateKeyLeafPreimage {
prefix: b"AGG_KEY_V1",
aggregate_bls_pubkey: &self.aggregate_bls_pubkey,
};
sha256(&preimage.to_bytes())
}

pub struct PersistentFinalizationStore<E> {
archive: PrunableArchive<FourCap, E, Digest, Vec<u8>>,
}
impl store::Certificates for PersistentFinalizationStore {
async fn put(
&mut self,
height: u64,
commitment: Digest,
finalization: SimplexFinalization<Scheme, Digest>,
) -> Result<()> {
let bytes = finalization.encode();
self.archive.put(height, commitment, bytes).await
}
}
async fn handle_finalized_block(&mut self, block: Block) {
let height = block.header.height;
// 1. Execute transactions
let meta = self.exec.apply_block(&block)?;
// 2. Update txpool
self.txpool.on_applied_block(height, &self.exec.state, &block.transactions);
// 3. ATOMIC: Persist block + finalized height
self.store.put_block_finalized(&block).await?;
self.finalized_height = height;
// 4. Finalize state
let root = self.exec.finalize_state(height)?;
// 5. Generate DA chunks
let bundle = self.chunk_producer.encode_block(&block);
self.pending_chunks.put(block_hash, bundle);
// 6. Update tip
self.tip = Some(Arc::new(block));
// 7. Notify DKG
self.dkg_finalization_tx.send(height);
// 8. WebSocket notifications
self.bus.emit_block(BlockNotification { ... });
}
async fn handle_tip_update(&mut self, height: u64, commitment: Digest) {
// Get finalization from marshal
let finalization = marshal_mailbox.get_finalization(height).await?;
// Get block
let block = self.store.get_block_by_height(height).await?;
// Create FinalityProof
let proof = FinalityProof::from_certificate(
block.header.clone(),
epoch,
view,
finalization.proposal.parent.get(),
finalization.certificate,
);
// Persist
self.store.put_finality_proof(&proof).await?;
self.latest_finality = Some(proof);
}

pub struct LightClientContext {
pub validator_set: ValidatorSet,
pub aggregate_key_proof: ValidatorProof, // Merkle proof
pub version: u32,
}
pub fn verify_finality_proof(
proof: &FinalityProof,
ctx: &LightClientContext,
) -> Result<(), LightClientError> {
// 1. Check validator set commitment
let root = ctx.commitment_root();
if root != proof.header.validator_set_id.id {
return Err(LightClientError::ValidatorSetIdMismatch);
}
// 2. Verify aggregate key membership proof
if ctx.aggregate_key_proof.leaf_index != 0 {
return Err(LightClientError::InvalidMembershipProof);
}
if !ctx.aggregate_key_proof.verify(&root) {
return Err(LightClientError::InvalidMembershipProof);
}
// 3. Verify threshold BLS signature
if !proof.verify(&ctx.validator_set.aggregate_bls_pubkey) {
return Err(LightClientError::InvalidSignature);
}
Ok(())
}
pub fn verify(&self, aggregate_bls_pubkey: &AggregateBlsPublicKey) -> bool {
// 1. Decode certificate
let certificate = self.decode_certificate()?;
// 2. Reconstruct proposal
let proposal = Proposal::new(
Round::new(Epoch::new(self.epoch), View::new(self.view)),
View::new(self.parent_view),
self.block_id().into(),
);
// 3. Create verifier with aggregate key
let scheme = Scheme::certificate_verifier(aggregate_bls_pubkey.0);
// 4. Verify threshold signature
scheme.verify_certificate(
&mut rng,
namespace(),
Subject::Finalize { proposal: &proposal },
&certificate,
)
}

When the validator set changes, the block header announces it:

pub struct BlockHeader {
pub validator_set_id: ValidatorSetId, // Current
pub next_validator_set_id: ValidatorSetId, // Next (if different)
// ...
}
pub struct LightClientState {
pub context: LightClientContext,
pub pending_update: Option<PendingUpdate>,
pub current_epoch: u64,
pub latest_height: u64,
}
pub fn process_finality_proof(&mut self, proof: &FinalityProof) -> Result<()> {
// Verify against current context
verify_finality_proof(proof, &self.context)?;
// Check for validator set change announcement
if proof.header.next_validator_set_id != proof.header.validator_set_id {
self.pending_update = Some(PendingUpdate {
next_validator_set_id: proof.header.next_validator_set_id.clone(),
announced_at_epoch: proof.header.epoch,
});
}
self.latest_height = proof.header.height;
Ok(())
}
pub fn apply_validator_set_update(
&mut self,
new_context: LightClientContext,
transition_proof: &FinalityProof,
) -> Result<()> {
let pending = self.pending_update.as_ref()?;
// Verify new commitment matches announced
if new_context.commitment_root() != pending.next_validator_set_id.id {
return Err(LightClientError::TransitionCommitmentMismatch);
}
// Verify proof with new context
verify_finality_proof(transition_proof, &new_context)?;
// Must be at epoch boundary
if transition_proof.header.epoch <= pending.announced_at_epoch {
return Err(LightClientError::TransitionNotAtEpochBoundary);
}
self.context = new_context;
self.pending_update = None;
Ok(())
}

BLOCK PROPOSAL
├─ Application: handle_propose_request()
│ ├─ Select transactions
│ ├─ Load parent VRF output
│ ├─ Build execution context
│ ├─ Compute state root
│ └─ Send block hash to Simplex
CONSENSUS VOTING (Simplex)
├─ Validators receive proposal
├─ Each validator signs with BLS share
│ └─ partial_sig = sign(share, proposal_hash)
├─ Collect 2f+1 partial signatures
└─ Aggregate: threshold_sig = sum(partial_sigs)
FINALIZATION CERTIFICATE (Marshal)
├─ Create SimplexFinalization
│ ├─ proposal: (round, parent_view, block_hash)
│ └─ certificate: aggregated BLS signature
├─ Encode to bytes
└─ Store in PersistentFinalizationStore
FINALITY PROOF (Application)
├─ FinalityProof::from_certificate()
│ ├─ header: BlockHeader
│ ├─ epoch, view, parent_view
│ ├─ key_version
│ └─ certificate: encoded threshold sig
├─ VRF output = Blake3(certificate.bytes)
└─ Persist to ChainStore
POST-FINALIZATION
├─ Execute transactions
├─ Update txpool
├─ Persist block (atomic with height)
├─ Finalize state
├─ Generate DA chunks
├─ Notify DKG coordinator
└─ WebSocket notifications
LIGHT CLIENT VERIFICATION
├─ 1. Check validator_set_id matches commitment_root
├─ 2. Verify aggregate key Merkle proof
│ ├─ leaf_index = 0
│ ├─ leaf_hash = aggregate_key_leaf_hash()
│ └─ Merkle path leads to root
└─ 3. Verify threshold BLS signature
├─ Decode certificate
├─ Reconstruct proposal
├─ Create verifier with aggregate key
└─ Verify over Subject::Finalize

ComponentTypeSizePurpose
Aggregate BLS KeyG2~96 bytesThreshold public key
Threshold SignatureG1~48 bytesAggregated 2f+1 votes
Block HashSHA25632 bytesPayload commitment
VRF OutputBlake332 bytesVerifiable randomness
Validator Set RootSHA25632 bytesMerkle commitment
Partial SignatureG1~48 bytesIndividual validator vote

PropertyMechanism
Threshold SecurityRequires 2f+1 of n validators
Non-RepudiationThreshold BLS proves collective commitment
Unbiasable RandomnessVRF unpredictable until threshold reached
Crash SafetyAtomic block + height persistence
Validator ContinuityEpoch boundary transitions verified
Light Client TrustSingle aggregate key + Merkle proof sufficient