Files
serai/processor/scanner/src/scan/mod.rs

368 lines
15 KiB
Rust
Raw Normal View History

use core::future::Future;
2024-08-28 23:45:17 -04:00
use std::collections::HashMap;
use borsh::BorshDeserialize;
2024-08-28 19:00:02 -04:00
use serai_db::{Get, DbTxn, Db};
#[rustfmt::skip]
use serai_primitives::instructions::{RefundableInInstruction, InInstruction, InInstructionWithBalance};
2024-08-23 21:21:02 -04:00
use primitives::{task::ContinuallyRan, OutputType, ReceivedOutput, Block};
2024-08-23 21:21:02 -04:00
use crate::{
lifetime::LifetimeStage,
2024-08-28 20:16:06 -04:00
db::{
OutputWithInInstruction, Returnable, SenderScanData, ScannerGlobalDb, InInstructionData,
2024-12-30 10:49:35 -05:00
ScanToBatchDb, ScanToEventualityDb,
2024-08-28 20:16:06 -04:00
},
BlockExt, ScannerFeed, AddressFor, OutputFor, Return, sort_outputs,
2024-08-28 18:43:40 -04:00
eventuality::latest_scannable_block,
2024-08-23 21:21:02 -04:00
};
2024-08-28 19:00:02 -04:00
mod db;
use db::ScanDb;
pub(crate) fn next_to_scan_for_outputs_block<S: ScannerFeed>(getter: &impl Get) -> Option<u64> {
ScanDb::<S>::next_to_scan_for_outputs_block(getter)
}
pub(crate) fn queue_output_until_block<S: ScannerFeed>(
txn: &mut impl DbTxn,
queue_for_block: u64,
output: &OutputWithInInstruction<S>,
) {
2024-08-28 19:58:28 -04:00
// This isn't a perfect assertion as by the time this txn commits, we may have already started
// scanning this block. That doesn't change it should never trip as we queue outside the window
// we'll scan
2024-08-28 19:00:02 -04:00
assert!(
queue_for_block >=
next_to_scan_for_outputs_block::<S>(txn)
.expect("queueing an output despite no next-to-scan-for-outputs block"),
"queueing an output for a block already scanned"
);
ScanDb::<S>::queue_output_until_block(txn, queue_for_block, output)
}
// Construct an InInstruction from an external output.
//
2024-08-23 21:21:02 -04:00
// Also returns the address to return the coins to upon error.
fn in_instruction_from_output<S: ScannerFeed>(
output: &OutputFor<S>,
) -> (Option<AddressFor<S>>, Option<InInstruction>) {
assert_eq!(output.kind(), OutputType::External);
let presumed_origin = output.presumed_origin();
let mut data = output.data();
let instruction = match RefundableInInstruction::deserialize_reader(&mut data) {
Ok(instruction) => instruction,
Err(e) => {
2024-08-23 21:21:02 -04:00
log::info!(
"data in output {} wasn't a valid `RefundableInInstruction`: {e:?}",
hex::encode(output.id()),
);
return (presumed_origin, None);
}
};
(
instruction
.return_address
.and_then(|addr| AddressFor::<S>::try_from(addr).ok())
.or(presumed_origin),
Some(instruction.instruction),
)
}
2024-08-28 20:16:06 -04:00
pub(crate) struct ScanTask<D: Db, S: ScannerFeed> {
db: D,
feed: S,
}
2024-08-28 20:16:06 -04:00
impl<D: Db, S: ScannerFeed> ScanTask<D, S> {
2024-08-28 19:00:02 -04:00
pub(crate) fn new(mut db: D, feed: S, start_block: u64) -> Self {
if ScanDb::<S>::next_to_scan_for_outputs_block(&db).is_none() {
// Initialize the DB
let mut txn = db.txn();
ScanDb::<S>::set_next_to_scan_for_outputs_block(&mut txn, start_block);
txn.commit();
}
Self { db, feed }
}
}
2024-08-28 20:16:06 -04:00
impl<D: Db, S: ScannerFeed> ContinuallyRan for ScanTask<D, S> {
type Error = String;
fn run_iteration(&mut self) -> impl Send + Future<Output = Result<bool, Self::Error>> {
async move {
// Fetch the safe to scan block
let latest_scannable =
latest_scannable_block::<S>(&self.db).expect("ScanTask run before writing the start block");
// Fetch the next block to scan
let next_to_scan = ScanDb::<S>::next_to_scan_for_outputs_block(&self.db)
.expect("ScanTask run before writing the start block");
for b in next_to_scan ..= latest_scannable {
let block = self.feed.block_by_number(&self.db, b).await?;
log::info!("scanning block: {} ({b})", hex::encode(block.id()));
let mut txn = self.db.txn();
assert_eq!(ScanDb::<S>::next_to_scan_for_outputs_block(&txn).unwrap(), b);
let keys = ScannerGlobalDb::<S>::active_keys_as_of_next_to_scan_for_outputs_block(&txn)
.expect("scanning for a blockchain without any keys set");
let latest_active_key = {
let mut keys = keys.clone();
loop {
// Use the most recent key
let key = keys.pop().unwrap();
// Unless this key is active, but not yet reporting
if key.stage == LifetimeStage::ActiveYetNotReporting {
continue;
}
break key.key;
}
};
// The scan data for this block
let mut scan_data = SenderScanData {
block_number: b,
received_external_outputs: vec![],
forwards: vec![],
returns: vec![],
};
// The InInstructions for this block
let mut in_instructions = vec![];
// The outputs queued for this block
let queued_outputs = {
let mut queued_outputs = ScanDb::<S>::take_queued_outputs(&mut txn, b);
// Sort the queued outputs in case they weren't queued in a deterministic fashion
queued_outputs.sort_by(|a, b| sort_outputs(&a.output, &b.output));
queued_outputs
};
for queued_output in queued_outputs {
in_instructions.push((
queued_output.output.id(),
Returnable {
return_address: queued_output.return_address,
in_instruction: queued_output.in_instruction,
},
));
scan_data.received_external_outputs.push(queued_output.output);
}
2024-08-20 18:20:28 -04:00
// We subtract the cost to aggregate from some outputs we scan
// This cost is fetched with an asynchronous function which may be non-trivial
// We cache the result of this function here to avoid calling it multiple times
let mut costs_to_aggregate = HashMap::with_capacity(1);
// Scan for each key
for key in &keys {
for output in block.scan_for_outputs(latest_active_key, key.key) {
assert_eq!(output.key(), key.key);
/*
The scan task runs ahead of time, obtaining ordering on the external network's blocks
with relation to events on the Serai network. This is done via publishing a Batch
which contains the InInstructions from External outputs. Accordingly, the scan
process only has to yield External outputs.
It'd appear to make sense to scan for all outputs, and after scanning for all
outputs, yield all outputs. The issue is we can't identify outputs we created here.
We can only identify the outputs we receive and their *declared intention*.
We only want to handle Change/Branch/Forwarded outputs we made ourselves. For
Forwarded, the reasoning is obvious (retiring multisigs should only downsize, yet
accepting new outputs solely because they claim to be Forwarded would increase the
size of the multisig). For Change/Branch, it's because such outputs which aren't ours
are pointless. They wouldn't hurt to accumulate though.
The issue is they would hurt to accumulate. We want to filter outputs which are less
than their cost to aggregate, a variable itself variable to the current blockchain.
We can filter such outputs here, yet if we drop a Change output, we create an
insolvency. We'd need to track the loss and offset it later. That means we can't
filter such outputs, as we expect any Change output we make.
The issue is the Change outputs we don't make. Someone can create an output declaring
to be Change, yet not actually Change. If we don't filter it, it'd be queued for
accumulation, yet it may cost more to accumulate than it's worth.
The solution is to let the Eventuality task, which does know if we made an output or
not (or rather, if a transaction is identical to a transaction which should exist
regarding effects) decide to keep/yield the outputs which we should only keep if we
made them (as Serai itself should not make worthless outputs, so we can assume
they're worthwhile, and even if they're not economically, they are technically).
The alternative, we drop outputs here with a generic filter rule and then report back
the insolvency created, still doesn't work as we'd only be creating an insolvency if
the output was actually made by us (and not simply someone else sending in). We can
have the Eventuality task report the insolvency, yet that requires the scanner be
responsible for such filter logic. It's more flexible, and has a cleaner API,
to do so at a higher level.
*/
if output.kind() != OutputType::External {
// While we don't report these outputs, we still need consensus on this block and
// accordingly still need to set it as notable
let balance = output.balance();
// We ensure it's over the dust limit to prevent people sending 1 satoshi from
// causing an invocation of a consensus/signing protocol
if balance.amount.0 >= S::dust(balance.coin).0 {
ScannerGlobalDb::<S>::flag_notable_due_to_non_external_output(&mut txn, b);
}
2024-08-20 18:20:28 -04:00
continue;
}
2024-08-23 21:21:02 -04:00
// Check this isn't dust
let balance_to_use = {
let mut balance = output.balance();
// First, subtract 2 * the cost to aggregate, as detailed in
// `spec/processor/UTXO Management.md`
// We cache this, so if it isn't yet cached, insert it into the cache
if let std::collections::hash_map::Entry::Vacant(e) =
costs_to_aggregate.entry(balance.coin)
{
e.insert(self.feed.cost_to_aggregate(balance.coin, &block).await.map_err(|e| {
format!(
"ScanTask couldn't fetch cost to aggregate {:?} at {b}: {e:?}",
balance.coin
)
})?);
}
let cost_to_aggregate = costs_to_aggregate[&balance.coin];
balance.amount.0 -= 2 * cost_to_aggregate.0;
2024-08-20 18:20:28 -04:00
// Now, check it's still past the dust threshold
if balance.amount.0 < S::dust(balance.coin).0 {
continue;
}
balance
};
// Fetch the InInstruction/return addr for this output
let output_with_in_instruction = match in_instruction_from_output::<S>(&output) {
(return_address, Some(instruction)) => OutputWithInInstruction {
output,
return_address,
in_instruction: InInstructionWithBalance { instruction, balance: balance_to_use },
},
(Some(address), None) => {
// Since there was no instruction here, return this since we parsed a return
// address
if key.stage != LifetimeStage::Finishing {
scan_data.returns.push(Return { address, output });
}
continue;
}
// Since we didn't receive an instruction nor can we return this, queue this for
// accumulation and move on
(None, None) => {
if key.stage != LifetimeStage::Finishing {
scan_data.received_external_outputs.push(output);
}
continue;
}
};
// Drop External outputs if they're to a multisig which won't report them
// This means we should report any External output we save to disk here
#[allow(clippy::match_same_arms)]
match key.stage {
// This multisig isn't yet reporting its External outputs to avoid a DoS
// Queue the output to be reported when this multisig starts reporting
LifetimeStage::ActiveYetNotReporting => {
ScanDb::<S>::queue_output_until_block(
&mut txn,
key.block_at_which_reporting_starts,
&output_with_in_instruction,
);
continue;
}
// We should report External outputs in these cases
LifetimeStage::Active | LifetimeStage::UsingNewForChange => {}
// We should report External outputs only once forwarded, where they'll appear as
// OutputType::Forwarded. We save them now for when they appear
LifetimeStage::Forwarding => {
// When the forwarded output appears, we can see which Plan it's associated with
// and from there recover this output
scan_data.forwards.push(output_with_in_instruction);
continue;
}
// We should drop these as we should not be handling new External outputs at this
// time
LifetimeStage::Finishing => {
continue;
}
}
// Ensures we didn't miss a `continue` above
assert!(matches!(key.stage, LifetimeStage::Active | LifetimeStage::UsingNewForChange));
in_instructions.push((
output_with_in_instruction.output.id(),
Returnable {
return_address: output_with_in_instruction.return_address,
in_instruction: output_with_in_instruction.in_instruction,
},
));
scan_data.received_external_outputs.push(output_with_in_instruction.output);
}
}
// Sort the InInstructions by the output ID
in_instructions.sort_by(|(output_id_a, _), (output_id_b, _)| {
use core::cmp::{Ordering, Ord};
let res = output_id_a.as_ref().cmp(output_id_b.as_ref());
assert!(res != Ordering::Equal, "two outputs within a collection had the same ID");
res
});
// Check we haven't prior reported an InInstruction for this output
// This is a sanity check which is intended to prevent multiple instances of sriXYZ
// on-chain due to a single output
for (id, _) in &in_instructions {
assert!(
!ScanDb::<S>::prior_reported_in_instruction_for_output(&txn, id),
"prior reported an InInstruction for an output with this ID"
);
ScanDb::<S>::reported_in_instruction_for_output(&mut txn, id);
}
// Reformat the InInstructions to just the InInstructions
let in_instructions = in_instructions
.into_iter()
.map(|(_id, in_instruction)| in_instruction)
.collect::<Vec<_>>();
// Send the InInstructions to the report task
// We need to also specify which key is responsible for signing the Batch for these, which
// will always be the oldest key (as the new key signing the Batch signifies handover
// acceptance)
2024-12-30 10:49:35 -05:00
ScanToBatchDb::<S>::send_in_instructions(
&mut txn,
b,
&InInstructionData {
session_to_sign_batch: keys[0].session,
external_key_for_session_to_sign_batch: keys[0].key,
returnable_in_instructions: in_instructions,
},
);
// Send the scan data to the eventuality task
ScanToEventualityDb::<S>::send_scan_data(&mut txn, b, &scan_data);
// Update the next to scan block
ScanDb::<S>::set_next_to_scan_for_outputs_block(&mut txn, b + 1);
txn.commit();
}
// Run dependents if we successfully scanned any blocks
Ok(next_to_scan <= latest_scannable)
}
}
}