Skip to content

DKG Flow

This document traces the complete DKG flow from epoch triggering through threshold key usage.

DKG generates threshold BLS keys each epoch. No single validator holds the full private key - instead, each validator holds a share that can produce partial signatures. Combining t-of-n partial signatures yields a valid threshold signature.

Epoch boundary approaches --> DKG triggered --> 4-phase protocol -->
Threshold keys generated --> Keys used for TLE decryption & VRF
PropertyValue
CurveBLS12-381 (MinSig variant)
Threshold~2/3 + 1 of validators
Public key typeG2 element
Signature/share typeG1 element / Scalar
Share encryptionX25519 ECDH + ChaCha20-Poly1305
Lead time10 blocks before epoch boundary

DKG is triggered when approaching an epoch boundary:

// In DkgDriver event loop
match event {
DkgDriverEvent::BlockFinalized { height } => {
// Check if we should start DKG for next epoch
if height % epoch_length >= epoch_length - lead_blocks {
let next_epoch = (height / epoch_length) + 1;
self.start_round(rng, next_epoch, participants)?;
}
}
}
DkgConfig {
commit_timeout: Duration::from_secs(30),
share_timeout: Duration::from_secs(30),
complaint_timeout: Duration::from_secs(30),
finalize_timeout: Duration::from_secs(30),
lead_blocks: 10, // Start DKG 10 blocks before epoch end
max_retries: 3,
retry_base_delay: Duration::from_secs(1),
retry_max_delay: Duration::from_secs(30),
}
fn start_round<R: Rng + CryptoRng>(
&mut self,
rng: &mut R,
epoch: u64,
participants: Set<PublicKey>,
) -> Result<(), DkgError> {
let n = participants.len() as u32;
let t = compute_threshold(n); // ~2/3 + 1
// Generate fresh polynomial and shares
let (polynomial, shares) = ops::generate_shares::<_, MinSig>(rng, None, n, t)?;
// Create round state
let round = DkgRoundState::new(epoch, participants, polynomial, shares);
self.coordinator.in_progress.insert(epoch, round);
Ok(())
}

Each validator acting as dealer broadcasts their polynomial commitment.

pub struct DkgCommitment {
pub epoch: u64,
pub dealer: Vec<u8>, // Dealer's ed25519 public key
pub commitment: Vec<u8>, // Serialized polynomial commitment (G2 elements)
pub signature: Vec<u8>, // ed25519 signature
}
// Domain separator
const COMMITMENT_DOMAIN: &[u8] = b"CHAIN-DKG-COMMITMENT-V1";
fn sign_commitment(epoch: u64, polynomial: &Poly<Evaluation>) -> DkgCommitment {
let commitment_bytes = serialize_polynomial(polynomial);
let message = [
COMMITMENT_DOMAIN,
&dealer_pubkey,
&epoch.to_le_bytes(),
&sha256(&commitment_bytes),
].concat();
let signature = ed25519_sign(&privkey, &message);
// ...
}
fn handle_commitment(&mut self, commitment: DkgCommitment) -> Result<(), DkgError> {
// 1. Validate dealer is in participant set
// 2. Verify ed25519 signature
// 3. Store in round.commitments
round.commitments.insert(dealer_pubkey, commitment.commitment);
Ok(())
}

Each dealer encrypts and sends a unique share to each participant.

pub struct DkgShareMessage {
pub epoch: u64,
pub dealer: Vec<u8>, // Dealer's ed25519 public key
pub recipient: Vec<u8>, // Recipient's ed25519 public key
pub encrypted_share: Vec<u8>, // X25519 + ChaCha20-Poly1305 ciphertext
pub signature: Vec<u8>, // ed25519 signature
}
fn encrypt_share(
recipient_ed25519: &[u8; 32],
share: &group::Share,
) -> Vec<u8> {
// 1. Convert ed25519 pubkey to X25519
let recipient_x25519 = ed25519_pubkey_to_x25519(recipient_ed25519);
// 2. Generate ephemeral X25519 keypair
let ephemeral_secret = EphemeralSecret::random_from_rng(rng);
let ephemeral_public = X25519PublicKey::from(&ephemeral_secret);
// 3. ECDH key exchange
let shared_secret = ephemeral_secret.diffie_hellman(&recipient_x25519);
// 4. Derive ChaCha20-Poly1305 key
let key = sha256(shared_secret.as_bytes());
// 5. Encrypt
let nonce = random_nonce_12();
let ciphertext = ChaCha20Poly1305::encrypt(&key, &nonce, &share_bytes);
// 6. Format: ephemeral_pubkey (32) || nonce (12) || ciphertext
[ephemeral_public.as_bytes(), &nonce, &ciphertext].concat()
}
fn decrypt_share(
my_ed25519_privkey: &[u8; 32],
encrypted_share: &[u8],
) -> Result<group::Share, DkgError> {
// 1. Parse ciphertext
let ephemeral_public = &encrypted_share[0..32];
let nonce = &encrypted_share[32..44];
let ciphertext = &encrypted_share[44..];
// 2. Convert ed25519 privkey to X25519
let my_x25519_secret = ed25519_privkey_to_x25519(my_ed25519_privkey);
// 3. ECDH with ephemeral public
let shared_secret = my_x25519_secret.diffie_hellman(ephemeral_public);
// 4. Derive same key
let key = sha256(shared_secret.as_bytes());
// 5. Decrypt
let plaintext = ChaCha20Poly1305::decrypt(&key, nonce, ciphertext)?;
// 6. Deserialize share
deserialize_share(&plaintext)
}

Handles disputes when shares are invalid or missing.

pub struct DkgComplaint {
pub epoch: u64,
pub complainer: Vec<u8>,
pub dealer: Vec<u8>,
pub reason: ComplaintReason, // MissingShare | InvalidShare | InvalidCommitment
pub signature: Vec<u8>,
}
pub struct DkgJustification {
pub epoch: u64,
pub dealer: Vec<u8>,
pub complainer: Vec<u8>,
pub revealed_share: Vec<u8>, // Share in plaintext for public verification
pub signature: Vec<u8>,
}

When a dealer receives a complaint, they can reveal the share publicly. All validators can then verify if the share matches the commitment.


When threshold conditions are met, finalize the DKG round.

if commitments.len() < threshold as usize {
return Err(DkgError::ThresholdNotMet { ... });
}
fn finalize_round(&mut self, epoch: u64) -> Result<DkgOutput, DkgError> {
let round = self.coordinator.in_progress.get(&epoch)?;
// 1. Deserialize all commitments
let commitments: Vec<Poly<Evaluation>> = round.commitments
.values()
.filter_map(|bytes| deserialize_polynomial(bytes).ok())
.collect();
// 2. Parallel verify all shares against commitments
let verified_shares: Vec<group::Share> = shares_to_verify
.par_iter()
.filter_map(|(dealer, share, dealer_idx)| {
let commitment = commitments.get(dealer_idx)?;
if ops::verify_share::<MinSig>(commitment, my_index, &share).is_ok() {
Some(share.clone())
} else {
None
}
})
.collect();
// 3. Include our own dealing share
verified_shares.push(our_dealing_share);
// 4. Aggregate public polynomial (combines all dealer commitments)
let aggregate_polynomial = ops::construct_public::<MinSig>(
commitments.iter(),
threshold
)?;
// 5. Aggregate shares (sum scalars - Shamir aggregation)
let aggregate_share = aggregate_shares(&verified_shares, my_index)?;
// 6. Create output
Ok(DkgOutput {
polynomial: aggregate_polynomial, // Collective BLS public key
share: aggregate_share, // Our threshold share
participants: participants.clone(),
})
}
pub struct DkgOutput {
pub polynomial: Poly<Evaluation>, // BLS12-381 polynomial (G2 for MinSig)
pub share: group::Share, // Our private threshold share (scalar)
pub participants: Set<PublicKey>, // Ordered participant list
}

The collective public key is the polynomial evaluated at 0: polynomial.evaluate(0).


After DKG completes, keys are registered for use:

pub fn register_dkg_output(
&self,
epoch: Epoch,
polynomial: Poly<Evaluation>,
share: group::Share,
participants: Set<PublicKey>,
) {
let mut inner = self.inner.write();
inner.dkg_polynomials.insert(epoch, polynomial);
inner.dkg_shares.insert(epoch, share);
}
pub fn dkg_polynomial(&self, epoch: Epoch) -> Option<Poly<Evaluation>> {
// Returns polynomial where P(0) = collective BLS public key
}
pub fn dkg_share(&self, epoch: Epoch) -> Option<group::Share> {
// Returns our threshold share for signing
}

pub fn seal(
plaintext: &[u8],
master_public: &G2, // Collective public key from DKG
epoch: u64,
) -> Result<TleSealedTransaction, ...> {
// Encrypt using collective public key
// Can only be decrypted with threshold signature
}

Each validator produces a partial signature:

pub fn sign_decryption_share(
share: &group::Share, // Our DKG share
epoch: u64,
) -> TleDecryptionShare {
let target = epoch.to_le_bytes();
let partial_sig = ops::sign_message::<MinSig>(
&share.secret,
Some(TLE_TARGET_PREFIX),
&target
);
// Returns G1 element
}

Leader combines t-of-n partial signatures:

pub fn combine_partial_signatures(
threshold: usize,
partial_sigs: &[(u16, G1Affine)],
) -> Result<G1Affine, ...> {
// Lagrange interpolation in G1
ops::threshold_signature_recover::<MinSig, _>(threshold, partial_sigs)
}

Threshold signature decrypts the ciphertext:

pub fn unseal(
&self,
threshold_sig: &G1Affine,
expected_epoch: u64,
) -> Result<Vec<u8>, ...> {
tle::decrypt::<MinSig>(threshold_sig, &ciphertext)
}

If DKG fails, the previous epoch’s keys can be used:

pub struct DkgCoordinatorState {
pub completed: BTreeMap<u64, DkgOutput>,
pub in_progress: BTreeMap<u64, DkgRoundState>,
pub fallback_epoch: Option<u64>, // Last successful epoch
pub last_epoch_used_fallback: Option<u64>,
}

Rules:

  • Can fallback to previous epoch’s keys if DKG fails
  • Cannot use fallback for two consecutive epochs
  • ConsecutiveFallback error if epoch N and N+1 both fail

EPOCH BOUNDARY APPROACHING (height % epoch_length >= epoch_length - lead_blocks)
DkgDriver triggered
├─ start_round(epoch, participants)
│ └─ ops::generate_shares() → polynomial + shares
PHASE 1: COMMITMENT (timeout: 30s)
├─ Each dealer broadcasts DkgCommitment
│ ├─ Serialize polynomial commitment (G2 elements)
│ ├─ Sign with ed25519
│ └─ Broadcast to all
├─ Validators receive and verify
│ └─ Store in round.commitments
PHASE 2: SHARE DISTRIBUTION (timeout: 30s)
├─ Each dealer sends DkgShareMessage to each recipient
│ ├─ Encrypt share (X25519 ECDH + ChaCha20-Poly1305)
│ ├─ Sign with ed25519
│ └─ Send to recipient
├─ Recipients decrypt and verify
│ └─ Store in round.shares
PHASE 3: COMPLAINTS & JUSTIFICATIONS (timeout: 30s)
├─ If share invalid/missing → DkgComplaint
├─ Dealer responds with DkgJustification (revealed share)
└─ Public verification against commitment
PHASE 4: FINALIZATION (timeout: 30s)
├─ Check threshold met (>= t commitments)
├─ Deserialize all commitments
├─ Parallel verify shares against commitments
├─ Aggregate public polynomial
│ └─ ops::construct_public() → Poly<G2>
├─ Aggregate shares (sum scalars)
│ └─ aggregate_shares() → group::Share
├─ Create DkgOutput { polynomial, share, participants }
└─ Mark complete: coordinator.completed[epoch] = output
REGISTRATION IN SUPERVISOR
├─ register_dkg_output(epoch, polynomial, share)
└─ Keys available for threshold operations
USAGE
├─ TLE Encryption: polynomial.evaluate(0) as master public key (G2)
├─ TLE Decryption:
│ ├─ Each validator: sign_decryption_share(share, epoch) → G1
│ ├─ Leader: combine_partial_signatures(t, partials) → G1
│ └─ Decrypt: tle::decrypt(threshold_sig, ciphertext)
└─ VRF: Similar threshold signature flow

BLS12-381 MinSig Variant:
├─ Public key: G2 element (96 bytes)
├─ Signature: G1 element (48 bytes)
└─ Secret: Scalar in Fr field
DKG Types:
├─ Poly<Evaluation>: Polynomial with G2 coefficients
├─ group::Share: { index: u32, secret: Scalar }
└─ Threshold signature: G1 element
Identity:
├─ Network identity: ed25519 public key (32 bytes)
└─ Share encryption: X25519 (derived from ed25519)