Add non-transaction-chaining scheduler

This commit is contained in:
Luke Parker
2024-09-04 03:54:12 -04:00
parent 0c1aec29bb
commit 6e9cb74022
17 changed files with 951 additions and 145 deletions

View File

@@ -9,7 +9,7 @@ to build and sign a transaction spending it.
The scheduler is designed to achieve fulfillment of all expected payments with
an `O(1)` delay (regardless of prior scheduler state), `O(log n)` time, and
`O(n)` computational complexity.
`O(log(n) + n)` computational complexity.
Due to the ability to chain transactions, we can immediately plan/sign dependent
transactions. For the time/computational complexity, we use a tree to fulfill

View File

@@ -7,7 +7,7 @@ use std::collections::HashMap;
use group::GroupEncoding;
use serai_primitives::{Coin, Amount, Balance};
use serai_primitives::{Coin, Amount};
use serai_db::DbTxn;
@@ -22,114 +22,6 @@ use utxo_scheduler_primitives::*;
mod db;
use db::Db;
#[derive(Clone)]
enum TreeTransaction<S: ScannerFeed> {
Leaves { payments: Vec<Payment<AddressFor<S>>>, value: u64 },
Branch { children: Vec<Self>, value: u64 },
}
impl<S: ScannerFeed> TreeTransaction<S> {
fn children(&self) -> usize {
match self {
Self::Leaves { payments, .. } => payments.len(),
Self::Branch { children, .. } => children.len(),
}
}
fn value(&self) -> u64 {
match self {
Self::Leaves { value, .. } | Self::Branch { value, .. } => *value,
}
}
fn payments(
&self,
coin: Coin,
branch_address: &AddressFor<S>,
input_value: u64,
) -> Option<Vec<Payment<AddressFor<S>>>> {
// Fetch the amounts for the payments we'll make
let mut amounts: Vec<_> = match self {
Self::Leaves { payments, .. } => {
payments.iter().map(|payment| Some(payment.balance().amount.0)).collect()
}
Self::Branch { children, .. } => children.iter().map(|child| Some(child.value())).collect(),
};
// We need to reduce them so their sum is our input value
assert!(input_value <= self.value());
let amount_to_amortize = self.value() - input_value;
// If any payments won't survive the reduction, set them to None
let mut amortized = 0;
'outer: while amounts.iter().any(Option::is_some) && (amortized < amount_to_amortize) {
let adjusted_fee = amount_to_amortize - amortized;
let amounts_len =
u64::try_from(amounts.iter().filter(|amount| amount.is_some()).count()).unwrap();
let per_payment_fee_check = adjusted_fee.div_ceil(amounts_len);
// Check each amount to see if it's not viable
let mut i = 0;
while i < amounts.len() {
if let Some(amount) = amounts[i] {
if amount.saturating_sub(per_payment_fee_check) < S::dust(coin).0 {
amounts[i] = None;
amortized += amount;
// If this amount wasn't viable, re-run with the new fee/amortization amounts
continue 'outer;
}
}
i += 1;
}
// Now that we have the payments which will survive, reduce them
for (i, amount) in amounts.iter_mut().enumerate() {
if let Some(amount) = amount {
*amount -= adjusted_fee / amounts_len;
if i < usize::try_from(adjusted_fee % amounts_len).unwrap() {
*amount -= 1;
}
}
}
break;
}
// Now that we have the reduced amounts, create the payments
let payments: Vec<_> = match self {
Self::Leaves { payments, .. } => {
payments
.iter()
.zip(amounts)
.filter_map(|(payment, amount)| {
amount.map(|amount| {
// The existing payment, with the new amount
Payment::new(
payment.address().clone(),
Balance { coin, amount: Amount(amount) },
payment.data().clone(),
)
})
})
.collect()
}
Self::Branch { .. } => {
amounts
.into_iter()
.filter_map(|amount| {
amount.map(|amount| {
// A branch output with the new amount
Payment::new(branch_address.clone(), Balance { coin, amount: Amount(amount) }, None)
})
})
.collect()
}
};
// Use None for vec![] so we never actually use vec![]
if payments.is_empty() {
None?;
}
Some(payments)
}
}
/// The outputs which will be effected by a PlannedTransaction and received by Serai.
pub struct EffectedReceivedOutputs<S: ScannerFeed>(Vec<OutputFor<S>>);
@@ -306,30 +198,8 @@ impl<S: ScannerFeed, P: TransactionPlanner<S, EffectedReceivedOutputs<S>>> Sched
assert!(Db::<S>::queued_payments(txn, key, coin).unwrap().is_empty());
}
// Create a tree to fulfillthe payments
// This variable is for the current layer of the tree being built
let mut tree = Vec::with_capacity(payments.len().div_ceil(P::MAX_OUTPUTS));
// Push the branches for the leaves (the payments out)
for payments in payments.chunks(P::MAX_OUTPUTS) {
let value = payments.iter().map(|payment| payment.balance().amount.0).sum::<u64>();
tree.push(TreeTransaction::<S>::Leaves { payments: payments.to_vec(), value });
}
// While we haven't calculated a tree root, or the tree root doesn't support a change output,
// keep working
while (tree.len() != 1) || (tree[0].children() == P::MAX_OUTPUTS) {
let mut branch_layer = vec![];
for children in tree.chunks(P::MAX_OUTPUTS) {
branch_layer.push(TreeTransaction::<S>::Branch {
children: children.to_vec(),
value: children.iter().map(TreeTransaction::value).sum(),
});
}
tree = branch_layer;
}
assert_eq!(tree.len(), 1);
assert!((tree[0].children() + 1) <= P::MAX_OUTPUTS);
// Create a tree to fulfill the payments
let mut tree = vec![P::tree(&payments)];
// Create the transaction for the root of the tree
let mut branch_outputs = {
@@ -343,7 +213,7 @@ impl<S: ScannerFeed, P: TransactionPlanner<S, EffectedReceivedOutputs<S>>> Sched
P::fee_rate(block, coin),
outputs.clone(),
tree[0]
.payments(coin, &branch_address, tree[0].value())
.payments::<S>(coin, &branch_address, tree[0].value())
.expect("payments were dropped despite providing an input of the needed value"),
Some(key_for_change),
) else {
@@ -355,7 +225,7 @@ impl<S: ScannerFeed, P: TransactionPlanner<S, EffectedReceivedOutputs<S>>> Sched
};
// If this doesn't have a change output, increase operating costs and try again
if !planned.auxilliary.0.iter().any(|output| output.kind() == OutputType::Change) {
if !planned.has_change {
/*
Since we'll create a change output if it's worth at least dust, amortizing dust from
the payments should solve this. If the new transaction can't afford those operating
@@ -399,11 +269,13 @@ impl<S: ScannerFeed, P: TransactionPlanner<S, EffectedReceivedOutputs<S>>> Sched
TreeTransaction::Branch { children, .. } => children,
};
while !tree.is_empty() {
// Sort the branch outputs by their value
// Sort the branch outputs by their value (high to low)
branch_outputs.sort_by_key(|a| a.balance().amount.0);
branch_outputs.reverse();
// Sort the transactions we should create by their value so they share an order with the
// branch outputs
tree.sort_by_key(TreeTransaction::value);
tree.reverse();
// If we dropped any Branch outputs, drop the associated children
tree.truncate(branch_outputs.len());
@@ -417,7 +289,8 @@ impl<S: ScannerFeed, P: TransactionPlanner<S, EffectedReceivedOutputs<S>>> Sched
for (branch_output, tx) in branch_outputs_for_this_layer.into_iter().zip(this_layer) {
assert_eq!(branch_output.kind(), OutputType::Branch);
let Some(payments) = tx.payments(coin, &branch_address, branch_output.balance().amount.0)
let Some(payments) =
tx.payments::<S>(coin, &branch_address, branch_output.balance().amount.0)
else {
// If this output has become too small to satisfy this branch, drop it
continue;
@@ -550,8 +423,9 @@ impl<S: ScannerFeed, P: TransactionPlanner<S, EffectedReceivedOutputs<S>>> Sched
// Fulfill the payments we prior couldn't
let mut eventualities = HashMap::new();
for (key, _stage) in active_keys {
eventualities
.insert(key.to_bytes().as_ref().to_vec(), Self::step(txn, active_keys, block, *key));
assert!(eventualities
.insert(key.to_bytes().as_ref().to_vec(), Self::step(txn, active_keys, block, *key))
.is_none());
}
// If this key has been flushed, forward all outputs