mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-08 20:29:23 +00:00
Replace Cosigns by extending NetworksLatestCosignedBlock
Cosigns was an archive of every single cosign ever received. By scoping NetworksLatestCosignedBlock to be by the global session, we have the latest cosign for each network in a session (valid to replace all prior cosigns by that network within that session, even for the purposes of fault) and automatically have the notable cosigns indexed (as they are the latest ones within their session). This not only saves space yet also allows optimizing evaluation a bit.
This commit is contained in:
@@ -14,6 +14,9 @@ rust-version = "1.81"
|
||||
all-features = true
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = ["scale"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -21,6 +24,7 @@ workspace = true
|
||||
blake2 = { version = "0.10", default-features = false, features = ["std"] }
|
||||
schnorrkel = { version = "0.11", default-features = false, features = ["std"] }
|
||||
|
||||
scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["std", "derive"] }
|
||||
borsh = { version = "1", default-features = false, features = ["std", "derive", "de_strict_order"] }
|
||||
serai-client = { path = "../../substrate/client", default-features = false, features = ["serai", "borsh"] }
|
||||
|
||||
|
||||
@@ -5,11 +5,18 @@ use serai_client::{primitives::Amount, Serai};
|
||||
use serai_db::*;
|
||||
use serai_task::ContinuallyRan;
|
||||
|
||||
use crate::{*, intend::BlockHasEvents};
|
||||
use crate::{
|
||||
*,
|
||||
intend::{BlockEventData, BlockEvents},
|
||||
};
|
||||
|
||||
create_db!(
|
||||
SubstrateCosignEvaluator {
|
||||
// The latest cosigned block number.
|
||||
LatestCosignedBlockNumber: () -> u64,
|
||||
// The latest global session evaluated.
|
||||
// TODO: Also include the weights here
|
||||
LatestGlobalSessionEvaluated: () -> ([u8; 32], Vec<ValidatorSet>),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -20,7 +27,23 @@ pub(crate) struct CosignEvaluatorTask<D: Db, R: RequestNotableCosigns> {
|
||||
pub(crate) request: R,
|
||||
}
|
||||
|
||||
// TODO: Add a cache for the stake values
|
||||
async fn get_latest_global_session_evaluated(
|
||||
txn: &mut impl DbTxn,
|
||||
serai: &Serai,
|
||||
parent_hash: [u8; 32],
|
||||
) -> Result<([u8; 32], Vec<ValidatorSet>), String> {
|
||||
Ok(match LatestGlobalSessionEvaluated::get(txn) {
|
||||
Some(res) => res,
|
||||
None => {
|
||||
// This is the initial global session
|
||||
// Fetch the sets participating and declare it the latest value recognized
|
||||
let sets = cosigning_sets_by_parent_hash(serai, parent_hash).await?;
|
||||
let initial_global_session = GlobalSession::new(sets.clone()).id();
|
||||
LatestGlobalSessionEvaluated::set(txn, &(initial_global_session, sets.clone()));
|
||||
(initial_global_session, sets)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl<D: Db, R: RequestNotableCosigns> ContinuallyRan for CosignEvaluatorTask<D, R> {
|
||||
fn run_iteration(&mut self) -> impl Send + Future<Output = Result<bool, String>> {
|
||||
@@ -31,23 +54,26 @@ impl<D: Db, R: RequestNotableCosigns> ContinuallyRan for CosignEvaluatorTask<D,
|
||||
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 };
|
||||
let Some(BlockEventData { block_number, parent_hash, block_hash, has_events }) =
|
||||
BlockEvents::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 (global_session, sets) =
|
||||
get_latest_global_session_evaluated(&mut txn, &self.serai, parent_hash).await?;
|
||||
|
||||
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",
|
||||
);
|
||||
@@ -68,9 +94,9 @@ impl<D: Db, R: RequestNotableCosigns> ContinuallyRan for CosignEvaluatorTask<D,
|
||||
total_weight += stake;
|
||||
|
||||
// Check if we have the cosign from this set
|
||||
if cosigns_for_block
|
||||
.iter()
|
||||
.any(|cosign| cosign.cosign.cosigner == Cosigner::ValidatorSet(set.network))
|
||||
if NetworksLatestCosignedBlock::get(&txn, global_session, set.network)
|
||||
.map(|signed_cosign| signed_cosign.cosign.block_number) ==
|
||||
Some(block_number)
|
||||
{
|
||||
// Since have this cosign, add the set's weight to the weight which has cosigned
|
||||
weight_cosigned += stake;
|
||||
@@ -90,6 +116,13 @@ impl<D: Db, R: RequestNotableCosigns> ContinuallyRan for CosignEvaluatorTask<D,
|
||||
"notable block (#{block_number}) wasn't yet cosigned. this should resolve shortly",
|
||||
));
|
||||
}
|
||||
|
||||
// Since this block changes the global session, update it
|
||||
{
|
||||
let sets = cosigning_sets(&self.serai.as_of(block_hash)).await?;
|
||||
let global_session = GlobalSession::new(sets.clone()).id();
|
||||
LatestGlobalSessionEvaluated::set(&mut txn, &(global_session, sets));
|
||||
}
|
||||
}
|
||||
// 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
|
||||
@@ -112,8 +145,8 @@ impl<D: Db, R: RequestNotableCosigns> ContinuallyRan for CosignEvaluatorTask<D,
|
||||
*/
|
||||
|
||||
// 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, sets) =
|
||||
get_latest_global_session_evaluated(&mut txn, &self.serai, parent_hash).await?;
|
||||
let (_, global_session_start_block) = GlobalSessions::get(&txn, global_session)
|
||||
.expect(
|
||||
"checking if intended cosign was satisfied within an unrecognized global session",
|
||||
@@ -136,7 +169,9 @@ impl<D: Db, R: RequestNotableCosigns> ContinuallyRan for CosignEvaluatorTask<D,
|
||||
total_weight += stake;
|
||||
|
||||
// Check if this set cosigned this block or not
|
||||
let Some(cosign) = NetworksLatestCosignedBlock::get(&txn, set.network) else {
|
||||
let Some(cosign) =
|
||||
NetworksLatestCosignedBlock::get(&txn, global_session, set.network)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if cosign.cosign.block_number >= block_number {
|
||||
@@ -167,6 +202,11 @@ impl<D: Db, R: RequestNotableCosigns> ContinuallyRan for CosignEvaluatorTask<D,
|
||||
}
|
||||
|
||||
// Update the cached result for the block we know is cosigned
|
||||
/*
|
||||
There may be a higher block which was cosigned, but once we get to this block,
|
||||
we'll re-evaluate and find it then. The alternative would be an optimistic
|
||||
re-evaluation now. Both are fine, so the lower-complexity option is preferred.
|
||||
*/
|
||||
known_cosign = lowest_common_block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,34 +13,40 @@ create_db!(
|
||||
}
|
||||
);
|
||||
|
||||
#[derive(Debug, BorshSerialize, BorshDeserialize)]
|
||||
pub(crate) struct BlockEventData {
|
||||
pub(crate) block_number: u64,
|
||||
pub(crate) parent_hash: [u8; 32],
|
||||
pub(crate) block_hash: [u8; 32],
|
||||
pub(crate) has_events: HasEvents,
|
||||
}
|
||||
|
||||
db_channel! {
|
||||
CosignIntendChannels {
|
||||
BlockHasEvents: () -> (u64, HasEvents),
|
||||
BlockEvents: () -> BlockEventData,
|
||||
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(),
|
||||
);
|
||||
block_number: u64,
|
||||
) -> Result<(Block, HasEvents), SeraiError> {
|
||||
let block = serai
|
||||
.finalized_block_by_number(block_number)
|
||||
.await?
|
||||
.expect("couldn't get block which should've been finalized");
|
||||
let serai = serai.as_of(block.hash());
|
||||
|
||||
if !serai.validator_sets().key_gen_events().await?.is_empty() {
|
||||
return Ok(HasEvents::Notable);
|
||||
return Ok((block, HasEvents::Notable));
|
||||
}
|
||||
|
||||
if !serai.coins().burn_with_instruction_events().await?.is_empty() {
|
||||
return Ok(HasEvents::NonNotable);
|
||||
return Ok((block, HasEvents::NonNotable));
|
||||
}
|
||||
|
||||
Ok(HasEvents::No)
|
||||
Ok((block, HasEvents::No))
|
||||
}
|
||||
|
||||
/// A task to determine which blocks we should intend to cosign.
|
||||
@@ -59,23 +65,22 @@ impl<D: Db> ContinuallyRan for CosignIntendTask<D> {
|
||||
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:?}"))?;
|
||||
let (block, 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?;
|
||||
let sets = cosigning_sets_for_block(&self.serai, &block).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()),
|
||||
);
|
||||
let global_session = GlobalSession::new(sets).id();
|
||||
GlobalSessions::set(&mut txn, global_session, &(block.number(), block.hash()));
|
||||
LatestGlobalSessionIntended::set(&mut txn, &global_session);
|
||||
}
|
||||
|
||||
// If this block doesn't have any cosigners, meaning it'll never be cosigned, we flag it
|
||||
@@ -104,7 +109,15 @@ impl<D: Db> ContinuallyRan for CosignIntendTask<D> {
|
||||
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));
|
||||
BlockEvents::send(
|
||||
&mut txn,
|
||||
&(BlockEventData {
|
||||
block_number,
|
||||
parent_hash: block.header.parent_hash.into(),
|
||||
block_hash: block.hash(),
|
||||
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();
|
||||
|
||||
@@ -62,16 +62,20 @@ 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.
|
||||
// The latest global session intended.
|
||||
//
|
||||
// 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<SignedCosign>,
|
||||
// This is distinct from the latest global session for which we've evaluated the cosigns for.
|
||||
LatestGlobalSessionIntended: () -> [u8; 32],
|
||||
// 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) -> SignedCosign,
|
||||
//
|
||||
// The global session changes upon a notable block, causing each global session to have exactly
|
||||
// one notable block. All validator sets will explicitly produce a cosign for their notable
|
||||
// block, causing the latest cosigned block for a global session to either be the global
|
||||
// session's notable cosigns or the network's latest cosigns.
|
||||
NetworksLatestCosignedBlock: (global_session: [u8; 32], network: NetworkId) -> SignedCosign,
|
||||
// Cosigns received for blocks not locally recognized as finalized.
|
||||
Faults: (global_session: [u8; 32]) -> Vec<SignedCosign>,
|
||||
// The global session which faulted.
|
||||
@@ -146,25 +150,6 @@ impl SignedCosign {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<'_>,
|
||||
@@ -216,13 +201,25 @@ async fn cosigning_sets(serai: &TemporalSerai<'_>) -> Result<Vec<ValidatorSet>,
|
||||
Ok(sets)
|
||||
}
|
||||
|
||||
/// Fetch the `ValidatorSet`s used for cosigning a block by the block's parent hash.
|
||||
async fn cosigning_sets_by_parent_hash(
|
||||
serai: &Serai,
|
||||
parent_hash: [u8; 32],
|
||||
) -> Result<Vec<ValidatorSet>, 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 until block `n` is cosigned).
|
||||
That's why fetching the cosigning sets for a block by its parent hash is valid.
|
||||
*/
|
||||
cosigning_sets(&serai.as_of(parent_hash)).await
|
||||
}
|
||||
|
||||
/// 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))
|
||||
block: &Block,
|
||||
) -> Result<Vec<ValidatorSet>, String> {
|
||||
cosigning_sets_by_parent_hash(serai, block.header.parent_hash.into()).await
|
||||
}
|
||||
|
||||
/// An object usable to request notable cosigns for a block.
|
||||
@@ -279,8 +276,17 @@ impl<D: Db> Cosigning<D> {
|
||||
}
|
||||
|
||||
/// Fetch the notable cosigns for a global session in order to respond to requests.
|
||||
///
|
||||
/// If this global session hasn't produced any notable cosigns, this will return the latest
|
||||
/// cosigns for this session.
|
||||
pub fn notable_cosigns(&self, global_session: [u8; 32]) -> Vec<SignedCosign> {
|
||||
todo!("TODO")
|
||||
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, global_session, network) {
|
||||
cosigns.push(cosign);
|
||||
}
|
||||
}
|
||||
cosigns
|
||||
}
|
||||
|
||||
/// The cosigns to rebroadcast ever so often.
|
||||
@@ -293,7 +299,7 @@ impl<D: Db> Cosigning<D> {
|
||||
// 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 let Some(cosign) = NetworksLatestCosignedBlock::get(&self.db, faulted, network) {
|
||||
if cosign.cosign.global_session == faulted {
|
||||
cosigns.push(cosign);
|
||||
}
|
||||
@@ -301,9 +307,14 @@ impl<D: Db> Cosigning<D> {
|
||||
}
|
||||
cosigns
|
||||
} else {
|
||||
let Some(latest_global_session) = LatestGlobalSessionIntended::get(&self.db) else {
|
||||
return vec![];
|
||||
};
|
||||
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) {
|
||||
if let Some(cosign) =
|
||||
NetworksLatestCosignedBlock::get(&self.db, latest_global_session, network)
|
||||
{
|
||||
cosigns.push(cosign);
|
||||
}
|
||||
}
|
||||
@@ -324,15 +335,24 @@ impl<D: Db> Cosigning<D> {
|
||||
// more relevant, cosign) again.
|
||||
//
|
||||
// Takes `&mut self` as this should only be called once at any given moment.
|
||||
// TODO: Don't overload bool here
|
||||
pub async fn intake_cosign(&mut self, signed_cosign: SignedCosign) -> Result<bool, String> {
|
||||
let cosign = &signed_cosign.cosign;
|
||||
|
||||
// Check if we've prior handled this cosign
|
||||
let Cosigner::ValidatorSet(network) = cosign.cosigner else {
|
||||
// TODO
|
||||
// Individually signed cosign despite that protocol not being implemented
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
// Check if we should even bother handling 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 == *cosign) {
|
||||
return Ok(true);
|
||||
// TODO: A malicious validator set can sign a block after their notable block to erase a
|
||||
// notable cosign
|
||||
if let Some(existing) = NetworksLatestCosignedBlock::get(&txn, cosign.global_session, network) {
|
||||
if existing.cosign.block_number >= cosign.block_number {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Check we can verify this cosign's signature
|
||||
@@ -342,24 +362,31 @@ impl<D: Db> Cosigning<D> {
|
||||
// Unrecognized global session
|
||||
return Ok(true);
|
||||
};
|
||||
if cosign.block_number <= global_session_start_block_number {
|
||||
// Cosign is for a block predating the global session
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 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);
|
||||
};
|
||||
{
|
||||
let key = match cosign.cosigner {
|
||||
Cosigner::ValidatorSet(network) => {
|
||||
// TODO: Cache this
|
||||
let Some((_session, keys)) =
|
||||
keys_for_network(&self.serai.as_of(global_session_start_block_hash), network).await?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if !signed_cosign.verify_signature(keys.0) {
|
||||
return Ok(false);
|
||||
keys.0
|
||||
}
|
||||
Cosigner::Validator(signer) => signer.into(),
|
||||
};
|
||||
|
||||
network
|
||||
if !signed_cosign.verify_signature(key) {
|
||||
return Ok(false);
|
||||
}
|
||||
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() <
|
||||
@@ -372,10 +399,6 @@ impl<D: Db> Cosigning<D> {
|
||||
// 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(signed_cosign.clone());
|
||||
Cosigns::set(&mut txn, cosign.block_number, &cosigns_for_this_block_position);
|
||||
|
||||
let our_block_hash = self
|
||||
.serai
|
||||
.block_hash(cosign.block_number)
|
||||
@@ -389,13 +412,7 @@ impl<D: Db> Cosigning<D> {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if NetworksLatestCosignedBlock::get(&txn, network)
|
||||
.map(|cosign| cosign.cosign.block_number)
|
||||
.unwrap_or(0) <
|
||||
cosign.block_number
|
||||
{
|
||||
NetworksLatestCosignedBlock::set(&mut txn, network, &signed_cosign);
|
||||
}
|
||||
NetworksLatestCosignedBlock::set(&mut txn, cosign.global_session, network, &signed_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
|
||||
|
||||
Reference in New Issue
Block a user