Dedicated library for intending and evaluating cosigns

Not only cleans the existing cosign code but enables non-Serai-coordinators to
evaluate cosigns if they gain access to a feed of them (such as over an RPC).
This would let centralized services not only track the finalized chain yet the
cosigned chain without directly running a coordinator.

Still being wrapped up.
This commit is contained in:
Luke Parker
2024-12-22 06:41:55 -05:00
parent 147a6e43d0
commit 4de1a5804d
13 changed files with 914 additions and 669 deletions

View File

@@ -0,0 +1,188 @@
use core::future::Future;
use serai_client::{primitives::Amount, Serai};
use serai_db::*;
use serai_task::ContinuallyRan;
use crate::{*, intend::BlockHasEvents};
create_db!(
SubstrateCosignEvaluator {
LatestCosignedBlockNumber: () -> u64,
}
);
/// A task to determine if a block has been cosigned and we should handle it.
pub(crate) struct CosignEvaluatorTask<D: Db, R: RequestNotableCosigns> {
pub(crate) db: D,
pub(crate) serai: Serai,
pub(crate) request: R,
}
// TODO: Add a cache for the stake values
impl<D: Db, R: RequestNotableCosigns> ContinuallyRan for CosignEvaluatorTask<D, R> {
fn run_iteration(&mut self) -> impl Send + Future<Output = Result<bool, String>> {
async move {
let latest_cosigned_block_number = LatestCosignedBlockNumber::get(&self.db).unwrap_or(0);
let mut known_cosign = None;
let mut made_progress = false;
loop {
let mut txn = self.db.txn();
let Some((block_number, has_events)) = BlockHasEvents::try_recv(&mut txn) else { break };
// Make sure these two feeds haven't desynchronized somehow
// We could remove our `LatestCosignedBlockNumber`, making the latest cosigned block number
// the next message in the channel's block number minus one, but that'd only work when the
// channel isn't empty
assert_eq!(block_number, latest_cosigned_block_number + 1);
let cosigns_for_block = Cosigns::get(&txn, block_number).unwrap_or(vec![]);
match has_events {
// Because this had notable events, we require an explicit cosign for this block by a
// supermajority of the prior block's validator sets
HasEvents::Notable => {
let mut weight_cosigned = 0;
let mut total_weight = 0;
let (_block, sets) = cosigning_sets_for_block(&self.serai, block_number).await?;
let global_session = GlobalSession::new(sets.clone()).id();
let (_, global_session_start_block) = GlobalSessions::get(&txn, global_session).expect(
"checking if intended cosign was satisfied within an unrecognized global session",
);
for set in sets {
// Fetch the weight for this set, as of the start of the global session
// This simplifies the logic around which set of stakes to use when evaluating
// cosigns, even if it's lossy as it isn't accurate to how stake may fluctuate within
// a session
let stake = self
.serai
.as_of(global_session_start_block)
.validator_sets()
.total_allocated_stake(set.network)
.await
.map_err(|e| format!("{e:?}"))?
.unwrap_or(Amount(0))
.0;
total_weight += stake;
// Check if we have the cosign from this set
if cosigns_for_block
.iter()
.any(|cosign| cosign.cosigner == Cosigner::ValidatorSet(set.network))
{
// Since have this cosign, add the set's weight to the weight which has cosigned
weight_cosigned += stake;
}
}
// Check if the sum weight doesn't cross the required threshold
if weight_cosigned < (((total_weight * 83) / 100) + 1) {
// Request the necessary cosigns over the network
// TODO: Add a timer to ensure this isn't called too often
self
.request
.request_notable_cosigns(global_session)
.await
.map_err(|e| format!("{e:?}"))?;
// We return an error so the delay before this task is run again increases
return Err(format!(
"notable block (#{block_number}) wasn't yet cosigned. this should resolve shortly",
));
}
}
// Since this block didn't have any notable events, we simply require a cosign for this
// block or a greater block by the current validator sets
HasEvents::NonNotable => {
// Check if this was satisfied by a cached result which wasn't calculated incrementally
let known_cosigned = if let Some(known_cosign) = known_cosign {
known_cosign >= block_number
} else {
// Clear `known_cosign` which is no longer helpful
known_cosign = None;
false
};
// If it isn't already known to be cosigned, evaluate the latest cosigns
if !known_cosigned {
/*
LatestCosign is populated with the latest cosigns for each network which don't
exceed the latest global session we've evaluated the start of. This current block
is during the latest global session we've evaluated the start of.
*/
// Get the global session for this block
let (_block, sets) = cosigning_sets_for_block(&self.serai, block_number).await?;
let global_session = GlobalSession::new(sets.clone()).id();
let (_, global_session_start_block) = GlobalSessions::get(&txn, global_session)
.expect(
"checking if intended cosign was satisfied within an unrecognized global session",
);
let mut weight_cosigned = 0;
let mut total_weight = 0;
let mut lowest_common_block: Option<u64> = None;
for set in sets {
let stake = self
.serai
.as_of(global_session_start_block)
.validator_sets()
.total_allocated_stake(set.network)
.await
.map_err(|e| format!("{e:?}"))?
.unwrap_or(Amount(0))
.0;
// Increment total_weight with this set's stake
total_weight += stake;
// Check if this set cosigned this block or not
let Some(cosign) = NetworksLatestCosignedBlock::get(&txn, set.network) else {
continue;
};
if cosign.block_number >= block_number {
weight_cosigned += total_weight
}
// Update the lowest block common to all of these cosigns
lowest_common_block = lowest_common_block
.map(|existing| existing.min(cosign.block_number))
.or(Some(cosign.block_number));
}
// Check if the sum weight doesn't cross the required threshold
if weight_cosigned < (((total_weight * 83) / 100) + 1) {
// Request the superseding notable cosigns over the network
// If this session hasn't yet produced notable cosigns, then we presume we'll see
// the desired non-notable cosigns as part of normal operations, without needing to
// explicitly request them
self
.request
.request_notable_cosigns(global_session)
.await
.map_err(|e| format!("{e:?}"))?;
// We return an error so the delay before this task is run again increases
return Err(format!(
"block (#{block_number}) wasn't yet cosigned. this should resolve shortly",
));
}
// Update the cached result for the block we know is cosigned
known_cosign = lowest_common_block;
}
}
// If this block has no events necessitating cosigning, we can immediately consider the
// block cosigned (making this block a NOP)
HasEvents::No => {}
}
// Since we checked we had the necessary cosigns, increment the latest cosigned block
LatestCosignedBlockNumber::set(&mut txn, &block_number);
txn.commit();
made_progress = true;
}
Ok(made_progress)
}
}
}

View File

@@ -0,0 +1,116 @@
use core::future::Future;
use serai_client::{SeraiError, Serai, validator_sets::primitives::ValidatorSet};
use serai_db::*;
use serai_task::ContinuallyRan;
use crate::*;
create_db!(
CosignIntend {
ScanCosignFrom: () -> u64,
}
);
db_channel! {
CosignIntendChannels {
BlockHasEvents: () -> (u64, HasEvents),
IntendedCosigns: (set: ValidatorSet) -> CosignIntent,
}
}
async fn block_has_events_justifying_a_cosign(
serai: &Serai,
block: u64,
) -> Result<HasEvents, SeraiError> {
let serai = serai.as_of(
serai
.finalized_block_by_number(block)
.await?
.expect("couldn't get block which should've been finalized")
.hash(),
);
if !serai.validator_sets().key_gen_events().await?.is_empty() {
return Ok(HasEvents::Notable);
}
if !serai.coins().burn_with_instruction_events().await?.is_empty() {
return Ok(HasEvents::NonNotable);
}
Ok(HasEvents::No)
}
/// A task to determine which blocks we should intend to cosign.
pub(crate) struct CosignIntendTask<D: Db> {
pub(crate) db: D,
pub(crate) serai: Serai,
}
impl<D: Db> ContinuallyRan for CosignIntendTask<D> {
fn run_iteration(&mut self) -> impl Send + Future<Output = Result<bool, String>> {
async move {
let start_block_number = ScanCosignFrom::get(&self.db).unwrap_or(1);
let latest_block_number =
self.serai.latest_finalized_block().await.map_err(|e| format!("{e:?}"))?.number();
for block_number in start_block_number ..= latest_block_number {
let mut txn = self.db.txn();
let mut has_events = block_has_events_justifying_a_cosign(&self.serai, block_number)
.await
.map_err(|e| format!("{e:?}"))?;
match has_events {
HasEvents::Notable | HasEvents::NonNotable => {
let (block, sets) = cosigning_sets_for_block(&self.serai, block_number).await?;
// If this is notable, it creates a new global session, which we index into the
// database now
if has_events == HasEvents::Notable {
let sets = cosigning_sets(&self.serai.as_of(block.hash())).await?;
GlobalSessions::set(
&mut txn,
GlobalSession::new(sets).id(),
&(block.number(), block.hash()),
);
}
// If this block doesn't have any cosigners, meaning it'll never be cosigned, we flag it
// as not having any events requiring cosigning so we don't attempt to sign/require a
// cosign for it
if sets.is_empty() {
has_events = HasEvents::No;
} else {
let global_session = GlobalSession::new(sets.clone()).id();
// Tell each set of their expectation to cosign this block
for set in sets {
log::debug!("{:?} will be cosigning block #{block_number}", set);
IntendedCosigns::send(
&mut txn,
set,
&CosignIntent {
global_session,
block_number,
block_hash: block.hash(),
notable: has_events == HasEvents::Notable,
},
);
}
}
}
HasEvents::No => {}
}
// Populate a singular feed with every block's status for the evluator to work off of
BlockHasEvents::send(&mut txn, &(block_number, has_events));
// Mark this block as handled, meaning we should scan from the next block moving on
ScanCosignFrom::set(&mut txn, &(block_number + 1));
txn.commit();
}
Ok(start_block_number <= latest_block_number)
}
}
}

View File

@@ -0,0 +1,416 @@
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
use core::{fmt::Debug, future::Future};
use blake2::{Digest, Blake2s256};
use borsh::{BorshSerialize, BorshDeserialize};
use serai_client::{
primitives::{Amount, NetworkId, SeraiAddress},
validator_sets::primitives::{Session, ValidatorSet, KeyPair},
Block, Serai, TemporalSerai,
};
use serai_db::*;
use serai_task::*;
/// The cosigns which are intended to be performed.
mod intend;
/// The evaluator of the cosigns.
mod evaluator;
use evaluator::LatestCosignedBlockNumber;
/// A 'global session', defined as all validator sets used for cosigning at a given moment.
///
/// We evaluate cosign faults within a global session. This ensures even if cosigners cosign
/// distinct blocks at distinct positions within a global session, we still identify the faults.
/*
There is the attack where a validator set is given an alternate blockchain with a key generation
event at block #n, while most validator sets are given a blockchain with a key generation event
at block number #(n+1). This prevents whoever has the alternate blockchain from verifying the
cosigns on the primary blockchain, and detecting the faults, if they use the keys as of the block
prior to the block being cosigned.
We solve this by binding cosigns to a global session ID, which has a specific start block, and
reading the keys from the start block. This means that so long as all validator sets agree on the
start of a global session, they can verify all cosigns produced by that session, regardless of
how it advances. Since agreeing on the start of a global session is mandated, there's no way to
have validator sets follow two distinct global sessions without breaking the bounds of the
cosigning protocol.
*/
#[derive(Debug, BorshSerialize, BorshDeserialize)]
struct GlobalSession {
cosigners: Vec<ValidatorSet>,
}
impl GlobalSession {
fn new(mut cosigners: Vec<ValidatorSet>) -> Self {
cosigners.sort_by_key(|a| borsh::to_vec(a).unwrap());
Self { cosigners }
}
fn id(&self) -> [u8; 32] {
Blake2s256::digest(borsh::to_vec(self).unwrap()).into()
}
}
create_db! {
Cosign {
// A mapping from a global session's ID to its start block (number, hash).
GlobalSessions: (global_session: [u8; 32]) -> (u64, [u8; 32]),
// An archive of all cosigns ever received.
//
// This will only be populated with cosigns predating or during the most recent global session
// to have its start cosigned.
Cosigns: (block_number: u64) -> Vec<Cosign>,
// The latest cosigned block for each network.
//
// This will only be populated with cosigns predating or during the most recent global session
// to have its start cosigned.
NetworksLatestCosignedBlock: (network: NetworkId) -> Cosign,
// Cosigns received for blocks not locally recognized as finalized.
Faults: (global_session: [u8; 32]) -> Vec<Cosign>,
// The global session which faulted.
FaultedSession: () -> [u8; 32],
}
}
/// If the block has events.
#[derive(Clone, Copy, PartialEq, Eq, Debug, BorshSerialize, BorshDeserialize)]
enum HasEvents {
/// The block had a notable event.
///
/// This is a special case as blocks with key gen events change the keys used for cosigning, and
/// accordingly must be cosigned before we advance past them.
Notable,
/// The block had an non-notable event justifying a cosign.
NonNotable,
/// The block didn't have an event justifying a cosign.
No,
}
/// An intended cosign.
#[derive(Clone, Copy, PartialEq, Eq, Debug, BorshSerialize, BorshDeserialize)]
struct CosignIntent {
/// The global session this cosign is being performed under.
global_session: [u8; 32],
/// The number of the block to cosign.
block_number: u64,
/// The hash of the block to cosign.
block_hash: [u8; 32],
/// If this cosign must be handled before further cosigns are.
notable: bool,
}
/// The identification of a cosigner.
#[derive(Clone, Copy, PartialEq, Eq, Debug, BorshSerialize, BorshDeserialize)]
enum Cosigner {
/// The network which produced this cosign.
ValidatorSet(NetworkId),
/// The individual validator which produced this cosign.
Validator(SeraiAddress),
}
/// A cosign.
#[derive(Clone, Copy, PartialEq, Eq, Debug, BorshSerialize, BorshDeserialize)]
struct Cosign {
/// The global session this cosign is being performed under.
global_session: [u8; 32],
/// The number of the block to cosign.
block_number: u64,
/// The hash of the block to cosign.
block_hash: [u8; 32],
/// The actual cosigner.
cosigner: Cosigner,
}
/// Construct a `TemporalSerai` bound to the time used for cosigning this block.
async fn temporal_serai_used_for_cosigning(
serai: &Serai,
block_number: u64,
) -> Result<(Block, TemporalSerai<'_>), String> {
let block = serai
.finalized_block_by_number(block_number)
.await
.map_err(|e| format!("{e:?}"))?
.ok_or("block wasn't finalized".to_string())?;
// If we're cosigning block `n`, it's cosigned by the sets as of block `n-1`
// (as block `n` may update the sets declared but that update shouldn't take effect here
// until it's cosigned)
let serai = serai.as_of(block.header.parent_hash.into());
Ok((block, serai))
}
/// Fetch the keys used for cosigning by a specific network.
async fn keys_for_network(
serai: &TemporalSerai<'_>,
network: NetworkId,
) -> Result<Option<(Session, KeyPair)>, String> {
let Some(latest_session) =
serai.validator_sets().session(network).await.map_err(|e| format!("{e:?}"))?
else {
// If this network hasn't had a session declared, move on
return Ok(None);
};
// Get the keys for the latest session
if let Some(keys) = serai
.validator_sets()
.keys(ValidatorSet { network, session: latest_session })
.await
.map_err(|e| format!("{e:?}"))?
{
return Ok(Some((latest_session, keys)));
}
// If the latest session has yet to set keys, use the prior session
if let Some(prior_session) = latest_session.0.checked_sub(1).map(Session) {
if let Some(keys) = serai
.validator_sets()
.keys(ValidatorSet { network, session: prior_session })
.await
.map_err(|e| format!("{e:?}"))?
{
return Ok(Some((prior_session, keys)));
}
}
Ok(None)
}
/// Fetch the `ValidatorSet`s used for cosigning as of this block.
async fn cosigning_sets(serai: &TemporalSerai<'_>) -> Result<Vec<ValidatorSet>, String> {
let mut sets = Vec::with_capacity(serai_client::primitives::NETWORKS.len());
for network in serai_client::primitives::NETWORKS {
let Some((session, _)) = keys_for_network(serai, network).await? else {
// If this network doesn't have usable keys, move on
continue;
};
sets.push(ValidatorSet { network, session });
}
Ok(sets)
}
/// Fetch the `ValidatorSet`s used for cosigning this block.
async fn cosigning_sets_for_block(
serai: &Serai,
block_number: u64,
) -> Result<(Block, Vec<ValidatorSet>), String> {
let (block, serai) = temporal_serai_used_for_cosigning(serai, block_number).await?;
cosigning_sets(&serai).await.map(|sets| (block, sets))
}
/// An object usable to request notable cosigns for a block.
pub trait RequestNotableCosigns: 'static + Send {
/// The error type which may be encountered when requesting notable cosigns.
type Error: Debug;
/// Request the notable cosigns for this global session.
fn request_notable_cosigns(
&self,
global_session: [u8; 32],
) -> impl Send + Future<Output = Result<(), Self::Error>>;
}
/// An error used to indicate the cosigning protocol has faulted.
pub struct Faulted;
/// The interface to manage cosigning with.
pub struct Cosigning<D: Db> {
db: D,
serai: Serai,
}
impl<D: Db> Cosigning<D> {
/// Spawn the tasks to intend and evaluate cosigns.
///
/// The database specified must only be used with a singular instance of the Serai network, and
/// only used once at any given time.
pub fn spawn<R: RequestNotableCosigns>(
db: D,
serai: Serai,
request: R,
tasks_to_run_upon_cosigning: Vec<TaskHandle>,
) -> Self {
let (intend_task, _intend_task_handle) = Task::new();
let (evaluator_task, evaluator_task_handle) = Task::new();
tokio::spawn(
(intend::CosignIntendTask { db: db.clone(), serai: serai.clone() })
.continually_run(intend_task, vec![evaluator_task_handle]),
);
tokio::spawn(
(evaluator::CosignEvaluatorTask { db: db.clone(), serai: serai.clone(), request })
.continually_run(evaluator_task, tasks_to_run_upon_cosigning),
);
Self { db, serai }
}
/// The latest cosigned block number.
pub fn latest_cosigned_block_number(&self) -> Result<u64, Faulted> {
if FaultedSession::get(&self.db).is_some() {
Err(Faulted)?;
}
Ok(LatestCosignedBlockNumber::get(&self.db).unwrap_or(0))
}
/// Fetch the notable cosigns for a global session in order to respond to requests.
pub fn notable_cosigns(&self, global_session: [u8; 32]) -> Vec<Cosign> {
todo!("TODO")
}
/// The cosigns to rebroadcast ever so often.
///
/// This will be the most recent cosigns, in case the initial broadcast failed, or the faulty
/// cosigns, in case of a fault, to induce identification of the fault by others.
pub fn cosigns_to_rebroadcast(&self) -> Vec<Cosign> {
if let Some(faulted) = FaultedSession::get(&self.db) {
let mut cosigns = Faults::get(&self.db, faulted).unwrap();
// Also include all of our recognized-as-honest cosigns in an attempt to induce fault
// identification in those who see the faulty cosigns as honest
for network in serai_client::primitives::NETWORKS {
if let Some(cosign) = NetworksLatestCosignedBlock::get(&self.db, network) {
if cosign.global_session == faulted {
cosigns.push(cosign);
}
}
}
cosigns
} else {
let mut cosigns = Vec::with_capacity(serai_client::primitives::NETWORKS.len());
for network in serai_client::primitives::NETWORKS {
if let Some(cosign) = NetworksLatestCosignedBlock::get(&self.db, network) {
cosigns.push(cosign);
}
}
cosigns
}
}
/// Intake a cosign from the Serai network.
///
/// - Returns Err(_) if there was an error trying to validate the cosign and it should be retired
/// later.
/// - Returns Ok(true) if the cosign was successfully handled or could not be handled at this
/// time.
/// - Returns Ok(false) if the cosign was invalid.
//
// We collapse a cosign which shouldn't be handled yet into a valid cosign (`Ok(true)`) as we
// assume we'll either explicitly request it if we need it or we'll naturally see it (or a later,
// more relevant, cosign) again.
//
// Takes `&mut self` as this should only be called once at any given moment.
pub async fn intake_cosign(&mut self, cosign: Cosign) -> Result<bool, String> {
// Check if we've prior handled this cosign
let mut txn = self.db.txn();
let mut cosigns_for_this_block_position =
Cosigns::get(&txn, cosign.block_number).unwrap_or(vec![]);
if cosigns_for_this_block_position.iter().any(|existing| *existing == cosign) {
return Ok(true);
}
// Check we can verify this cosign's signature
let Some((global_session_start_block_number, global_session_start_block_hash)) =
GlobalSessions::get(&txn, cosign.global_session)
else {
// Unrecognized global session
return Ok(true);
};
// Check the cosign's signature
let network = match cosign.cosigner {
Cosigner::ValidatorSet(network) => {
let Some((_session, keys)) =
keys_for_network(&self.serai.as_of(global_session_start_block_hash), network).await?
else {
return Ok(false);
};
todo!("TODO");
network
}
Cosigner::Validator(_) => return Ok(false),
};
// Check our finalized blockchain exceeds this block number
if self.serai.latest_finalized_block().await.map_err(|e| format!("{e:?}"))?.number() <
cosign.block_number
{
// Unrecognized block number
return Ok(true);
}
// Since we verified this cosign's signature, and have a chain sufficiently long, handle the
// cosign
// Save the cosign to the database
cosigns_for_this_block_position.push(cosign);
Cosigns::set(&mut txn, cosign.block_number, &cosigns_for_this_block_position);
let our_block_hash = self
.serai
.block_hash(cosign.block_number)
.await
.map_err(|e| format!("{e:?}"))?
.expect("requested hash of a finalized block yet received None");
if our_block_hash == cosign.block_hash {
// If this is for a future global session, we don't acknowledge this cosign at this time
if global_session_start_block_number > LatestCosignedBlockNumber::get(&self.db).unwrap_or(0) {
drop(txn);
return Ok(true);
}
if NetworksLatestCosignedBlock::get(&txn, network)
.map(|cosign| cosign.block_number)
.unwrap_or(0) <
cosign.block_number
{
NetworksLatestCosignedBlock::set(&mut txn, network, &cosign);
}
} else {
let mut faults = Faults::get(&txn, cosign.global_session).unwrap_or(vec![]);
// Only handle this as a fault if this set wasn't prior faulty
if !faults.iter().any(|cosign| cosign.cosigner == Cosigner::ValidatorSet(network)) {
faults.push(cosign);
Faults::set(&mut txn, cosign.global_session, &faults);
let mut weight_cosigned = 0;
let mut total_weight = 0;
for set in cosigning_sets(&self.serai.as_of(global_session_start_block_hash)).await? {
let stake = self
.serai
.as_of(global_session_start_block_hash)
.validator_sets()
.total_allocated_stake(set.network)
.await
.map_err(|e| format!("{e:?}"))?
.unwrap_or(Amount(0))
.0;
// Increment total_weight with this set's stake
total_weight += stake;
// Check if this set cosigned this block or not
if faults.iter().any(|cosign| cosign.cosigner == Cosigner::ValidatorSet(set.network)) {
weight_cosigned += total_weight
}
}
// Check if the sum weight means a fault has occurred
assert!(
total_weight != 0,
"evaluating valid cosign when no stake was present in the system"
);
if weight_cosigned >= ((total_weight * 17) / 100) {
FaultedSession::set(&mut txn, &cosign.global_session);
}
}
}
txn.commit();
Ok(true)
}
}