mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-08 20:29:23 +00:00
Allow scheduler's creation of transactions to be async and error
I don't love this, but it's the only way to select decoys without using a local database. While the prior commit added such a databse, the performance of it presumably wasn't viable, and while TODOs marked the needed improvements, it was still messy with an immense scope re: any auditing. The relevant scheduler functions now take `&self` (intentional, as all mutations should be via the `&mut impl DbTxn` passed). The calls to `&self` are expected to be completely deterministic (as usual).
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
use core::{fmt::Debug, future::Future};
|
||||
|
||||
use serai_primitives::{Coin, Amount};
|
||||
|
||||
use primitives::{ReceivedOutput, Payment};
|
||||
@@ -40,8 +42,14 @@ pub struct AmortizePlannedTransaction<S: ScannerFeed, ST: SignableTransaction, A
|
||||
|
||||
/// An object able to plan a transaction.
|
||||
pub trait TransactionPlanner<S: ScannerFeed, A>: 'static + Send + Sync {
|
||||
/// An error encountered when handling planning transactions.
|
||||
///
|
||||
/// This MUST be an ephemeral error. Retrying planning transactions MUST eventually resolve
|
||||
/// resolve manual intervention/changing the arguments.
|
||||
type EphemeralError: Debug;
|
||||
|
||||
/// The type representing a fee rate to use for transactions.
|
||||
type FeeRate: Clone + Copy;
|
||||
type FeeRate: Send + Clone + Copy;
|
||||
|
||||
/// The type representing a signable transaction.
|
||||
type SignableTransaction: SignableTransaction;
|
||||
@@ -82,11 +90,15 @@ pub trait TransactionPlanner<S: ScannerFeed, A>: 'static + Send + Sync {
|
||||
/// `change` will always be an address belonging to the Serai network. If it is `Some`, a change
|
||||
/// output must be created.
|
||||
fn plan(
|
||||
&self,
|
||||
fee_rate: Self::FeeRate,
|
||||
inputs: Vec<OutputFor<S>>,
|
||||
payments: Vec<Payment<AddressFor<S>>>,
|
||||
change: Option<KeyFor<S>>,
|
||||
) -> PlannedTransaction<S, Self::SignableTransaction, A>;
|
||||
) -> impl Send
|
||||
+ Future<
|
||||
Output = Result<PlannedTransaction<S, Self::SignableTransaction, A>, Self::EphemeralError>,
|
||||
>;
|
||||
|
||||
/// Obtain a PlannedTransaction via amortizing the fee over the payments.
|
||||
///
|
||||
@@ -98,132 +110,142 @@ pub trait TransactionPlanner<S: ScannerFeed, A>: 'static + Send + Sync {
|
||||
/// Returns `None` if the fee exceeded the inputs, or `Some` otherwise.
|
||||
// TODO: Enum for Change of None, Some, Mandatory
|
||||
fn plan_transaction_with_fee_amortization(
|
||||
&self,
|
||||
operating_costs: &mut u64,
|
||||
fee_rate: Self::FeeRate,
|
||||
inputs: Vec<OutputFor<S>>,
|
||||
mut payments: Vec<Payment<AddressFor<S>>>,
|
||||
mut change: Option<KeyFor<S>>,
|
||||
) -> Option<AmortizePlannedTransaction<S, Self::SignableTransaction, A>> {
|
||||
// If there's no change output, we can't recoup any operating costs we would amortize
|
||||
// We also don't have any losses if the inputs are written off/the change output is reduced
|
||||
let mut operating_costs_if_no_change = 0;
|
||||
let operating_costs_in_effect =
|
||||
if change.is_none() { &mut operating_costs_if_no_change } else { operating_costs };
|
||||
) -> impl Send
|
||||
+ Future<
|
||||
Output = Result<
|
||||
Option<AmortizePlannedTransaction<S, Self::SignableTransaction, A>>,
|
||||
Self::EphemeralError,
|
||||
>,
|
||||
> {
|
||||
async move {
|
||||
// If there's no change output, we can't recoup any operating costs we would amortize
|
||||
// We also don't have any losses if the inputs are written off/the change output is reduced
|
||||
let mut operating_costs_if_no_change = 0;
|
||||
let operating_costs_in_effect =
|
||||
if change.is_none() { &mut operating_costs_if_no_change } else { operating_costs };
|
||||
|
||||
// Sanity checks
|
||||
{
|
||||
assert!(!inputs.is_empty());
|
||||
assert!((!payments.is_empty()) || change.is_some());
|
||||
let coin = inputs.first().unwrap().balance().coin;
|
||||
for input in &inputs {
|
||||
assert_eq!(coin, input.balance().coin);
|
||||
}
|
||||
for payment in &payments {
|
||||
assert_eq!(coin, payment.balance().coin);
|
||||
}
|
||||
assert!(
|
||||
(inputs.iter().map(|input| input.balance().amount.0).sum::<u64>() +
|
||||
*operating_costs_in_effect) >=
|
||||
payments.iter().map(|payment| payment.balance().amount.0).sum::<u64>(),
|
||||
"attempted to fulfill payments without a sufficient input set"
|
||||
);
|
||||
}
|
||||
|
||||
// Sanity checks
|
||||
{
|
||||
assert!(!inputs.is_empty());
|
||||
assert!((!payments.is_empty()) || change.is_some());
|
||||
let coin = inputs.first().unwrap().balance().coin;
|
||||
for input in &inputs {
|
||||
assert_eq!(coin, input.balance().coin);
|
||||
|
||||
// Amortization
|
||||
{
|
||||
// Sort payments from high amount to low amount
|
||||
payments.sort_by(|a, b| a.balance().amount.0.cmp(&b.balance().amount.0).reverse());
|
||||
|
||||
let mut fee = Self::calculate_fee(fee_rate, inputs.clone(), payments.clone(), change).0;
|
||||
let mut amortized = 0;
|
||||
while !payments.is_empty() {
|
||||
// We need to pay the fee, and any accrued operating costs, minus what we've already
|
||||
// amortized
|
||||
let adjusted_fee = (*operating_costs_in_effect + fee).saturating_sub(amortized);
|
||||
|
||||
/*
|
||||
Ideally, we wouldn't use a ceil div yet would be accurate about it. Any remainder could
|
||||
be amortized over the largest outputs, which wouldn't be relevant here as we only work
|
||||
with the smallest output. The issue is the theoretical edge case where all outputs have
|
||||
the same value and are of the minimum value. In that case, none would be able to have
|
||||
the remainder amortized as it'd cause them to need to be dropped. Using a ceil div
|
||||
avoids this.
|
||||
*/
|
||||
let per_payment_fee = adjusted_fee.div_ceil(u64::try_from(payments.len()).unwrap());
|
||||
// Pop the last payment if it can't pay the fee, remaining about the dust limit as it does
|
||||
if payments.last().unwrap().balance().amount.0 <= (per_payment_fee + S::dust(coin).0) {
|
||||
amortized += payments.pop().unwrap().balance().amount.0;
|
||||
// Recalculate the fee and try again
|
||||
fee = Self::calculate_fee(fee_rate, inputs.clone(), payments.clone(), change).0;
|
||||
continue;
|
||||
}
|
||||
// Break since all of these payments shouldn't be dropped
|
||||
break;
|
||||
}
|
||||
|
||||
// If we couldn't amortize the fee over the payments, check if we even have enough to pay it
|
||||
if payments.is_empty() {
|
||||
// If we don't have a change output, we simply return here
|
||||
// We no longer have anything to do here, nor any expectations
|
||||
if change.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let inputs = inputs.iter().map(|input| input.balance().amount.0).sum::<u64>();
|
||||
// Checks not just if we can pay for it, yet that the would-be change output is at least
|
||||
// dust
|
||||
if inputs < (fee + S::dust(coin).0) {
|
||||
// Write off these inputs
|
||||
*operating_costs_in_effect += inputs;
|
||||
// Yet also claw back the payments we dropped, as we only lost the change
|
||||
// The dropped payments will be worth less than the inputs + operating_costs we started
|
||||
// with, so this shouldn't use `saturating_sub`
|
||||
*operating_costs_in_effect -= amortized;
|
||||
return Ok(None);
|
||||
}
|
||||
} else {
|
||||
// Since we have payments which can pay the fee we ended up with, amortize it
|
||||
let adjusted_fee = (*operating_costs_in_effect + fee).saturating_sub(amortized);
|
||||
let per_payment_base_fee = adjusted_fee / u64::try_from(payments.len()).unwrap();
|
||||
let payments_paying_one_atomic_unit_more =
|
||||
usize::try_from(adjusted_fee % u64::try_from(payments.len()).unwrap()).unwrap();
|
||||
|
||||
for (i, payment) in payments.iter_mut().enumerate() {
|
||||
let per_payment_fee =
|
||||
per_payment_base_fee + u64::from(u8::from(i < payments_paying_one_atomic_unit_more));
|
||||
payment.balance().amount.0 -= per_payment_fee;
|
||||
amortized += per_payment_fee;
|
||||
}
|
||||
assert!(amortized >= (*operating_costs_in_effect + fee));
|
||||
|
||||
// If the change is less than the dust, drop it
|
||||
let would_be_change = inputs.iter().map(|input| input.balance().amount.0).sum::<u64>() -
|
||||
payments.iter().map(|payment| payment.balance().amount.0).sum::<u64>() -
|
||||
fee;
|
||||
if would_be_change < S::dust(coin).0 {
|
||||
change = None;
|
||||
*operating_costs_in_effect += would_be_change;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the amount of operating costs
|
||||
*operating_costs_in_effect = (*operating_costs_in_effect + fee).saturating_sub(amortized);
|
||||
}
|
||||
for payment in &payments {
|
||||
assert_eq!(coin, payment.balance().coin);
|
||||
}
|
||||
assert!(
|
||||
(inputs.iter().map(|input| input.balance().amount.0).sum::<u64>() +
|
||||
*operating_costs_in_effect) >=
|
||||
payments.iter().map(|payment| payment.balance().amount.0).sum::<u64>(),
|
||||
"attempted to fulfill payments without a sufficient input set"
|
||||
);
|
||||
|
||||
// Because we amortized, or accrued as operating costs, the fee, make the transaction
|
||||
let effected_payments = payments.iter().map(|payment| payment.balance().amount).collect();
|
||||
let has_change = change.is_some();
|
||||
|
||||
let PlannedTransaction { signable, eventuality, auxilliary } =
|
||||
self.plan(fee_rate, inputs, payments, change).await?;
|
||||
Ok(Some(AmortizePlannedTransaction {
|
||||
effected_payments,
|
||||
has_change,
|
||||
signable,
|
||||
eventuality,
|
||||
auxilliary,
|
||||
}))
|
||||
}
|
||||
|
||||
let coin = inputs.first().unwrap().balance().coin;
|
||||
|
||||
// Amortization
|
||||
{
|
||||
// Sort payments from high amount to low amount
|
||||
payments.sort_by(|a, b| a.balance().amount.0.cmp(&b.balance().amount.0).reverse());
|
||||
|
||||
let mut fee = Self::calculate_fee(fee_rate, inputs.clone(), payments.clone(), change).0;
|
||||
let mut amortized = 0;
|
||||
while !payments.is_empty() {
|
||||
// We need to pay the fee, and any accrued operating costs, minus what we've already
|
||||
// amortized
|
||||
let adjusted_fee = (*operating_costs_in_effect + fee).saturating_sub(amortized);
|
||||
|
||||
/*
|
||||
Ideally, we wouldn't use a ceil div yet would be accurate about it. Any remainder could
|
||||
be amortized over the largest outputs, which wouldn't be relevant here as we only work
|
||||
with the smallest output. The issue is the theoretical edge case where all outputs have
|
||||
the same value and are of the minimum value. In that case, none would be able to have the
|
||||
remainder amortized as it'd cause them to need to be dropped. Using a ceil div avoids
|
||||
this.
|
||||
*/
|
||||
let per_payment_fee = adjusted_fee.div_ceil(u64::try_from(payments.len()).unwrap());
|
||||
// Pop the last payment if it can't pay the fee, remaining about the dust limit as it does
|
||||
if payments.last().unwrap().balance().amount.0 <= (per_payment_fee + S::dust(coin).0) {
|
||||
amortized += payments.pop().unwrap().balance().amount.0;
|
||||
// Recalculate the fee and try again
|
||||
fee = Self::calculate_fee(fee_rate, inputs.clone(), payments.clone(), change).0;
|
||||
continue;
|
||||
}
|
||||
// Break since all of these payments shouldn't be dropped
|
||||
break;
|
||||
}
|
||||
|
||||
// If we couldn't amortize the fee over the payments, check if we even have enough to pay it
|
||||
if payments.is_empty() {
|
||||
// If we don't have a change output, we simply return here
|
||||
// We no longer have anything to do here, nor any expectations
|
||||
if change.is_none() {
|
||||
None?;
|
||||
}
|
||||
|
||||
let inputs = inputs.iter().map(|input| input.balance().amount.0).sum::<u64>();
|
||||
// Checks not just if we can pay for it, yet that the would-be change output is at least
|
||||
// dust
|
||||
if inputs < (fee + S::dust(coin).0) {
|
||||
// Write off these inputs
|
||||
*operating_costs_in_effect += inputs;
|
||||
// Yet also claw back the payments we dropped, as we only lost the change
|
||||
// The dropped payments will be worth less than the inputs + operating_costs we started
|
||||
// with, so this shouldn't use `saturating_sub`
|
||||
*operating_costs_in_effect -= amortized;
|
||||
None?;
|
||||
}
|
||||
} else {
|
||||
// Since we have payments which can pay the fee we ended up with, amortize it
|
||||
let adjusted_fee = (*operating_costs_in_effect + fee).saturating_sub(amortized);
|
||||
let per_payment_base_fee = adjusted_fee / u64::try_from(payments.len()).unwrap();
|
||||
let payments_paying_one_atomic_unit_more =
|
||||
usize::try_from(adjusted_fee % u64::try_from(payments.len()).unwrap()).unwrap();
|
||||
|
||||
for (i, payment) in payments.iter_mut().enumerate() {
|
||||
let per_payment_fee =
|
||||
per_payment_base_fee + u64::from(u8::from(i < payments_paying_one_atomic_unit_more));
|
||||
payment.balance().amount.0 -= per_payment_fee;
|
||||
amortized += per_payment_fee;
|
||||
}
|
||||
assert!(amortized >= (*operating_costs_in_effect + fee));
|
||||
|
||||
// If the change is less than the dust, drop it
|
||||
let would_be_change = inputs.iter().map(|input| input.balance().amount.0).sum::<u64>() -
|
||||
payments.iter().map(|payment| payment.balance().amount.0).sum::<u64>() -
|
||||
fee;
|
||||
if would_be_change < S::dust(coin).0 {
|
||||
change = None;
|
||||
*operating_costs_in_effect += would_be_change;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the amount of operating costs
|
||||
*operating_costs_in_effect = (*operating_costs_in_effect + fee).saturating_sub(amortized);
|
||||
}
|
||||
|
||||
// Because we amortized, or accrued as operating costs, the fee, make the transaction
|
||||
let effected_payments = payments.iter().map(|payment| payment.balance().amount).collect();
|
||||
let has_change = change.is_some();
|
||||
let PlannedTransaction { signable, eventuality, auxilliary } =
|
||||
Self::plan(fee_rate, inputs, payments, change);
|
||||
Some(AmortizePlannedTransaction {
|
||||
effected_payments,
|
||||
has_change,
|
||||
signable,
|
||||
eventuality,
|
||||
auxilliary,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a tree to fulfill a set of payments.
|
||||
|
||||
Reference in New Issue
Block a user