Add processor/scheduler/utxo/primitives

Includes the necessary signing functions and the fee amortization logic.

Moves transaction-chaining to utxo/transaction-chaining.
This commit is contained in:
Luke Parker 2024-09-01 00:01:01 -04:00
parent fc765bb9e0
commit bd277e7032
12 changed files with 240 additions and 1 deletions

View file

@ -42,6 +42,7 @@ jobs:
-p serai-processor-key-gen \
-p serai-processor-frost-attempt-manager \
-p serai-processor-primitives \
-p serai-processor-utxo-scheduler-primitives \
-p serai-processor-transaction-chaining-scheduler \
-p serai-processor-scanner \
-p serai-processor \

14
Cargo.lock generated
View file

@ -8709,6 +8709,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "serai-processor-transaction-chaining-scheduler"
version = "0.1.0"
[[package]]
name = "serai-processor-utxo-scheduler-primitives"
version = "0.1.0"
dependencies = [
"async-trait",
"serai-primitives",
"serai-processor-primitives",
"serai-processor-scanner",
]
[[package]]
name = "serai-reproducible-runtime-tests"
version = "0.1.0"

View file

@ -74,7 +74,8 @@ members = [
"processor/frost-attempt-manager",
"processor/primitives",
"processor/scheduler/transaction-chaining",
"processor/scheduler/utxo/primitives",
"processor/scheduler/utxo/transaction-chaining",
"processor/scanner",
"processor",

View file

@ -49,6 +49,7 @@ exceptions = [
{ allow = ["AGPL-3.0"], name = "serai-processor-key-gen" },
{ allow = ["AGPL-3.0"], name = "serai-processor-frost-attempt-manager" },
{ allow = ["AGPL-3.0"], name = "serai-processor-utxo-primitives" },
{ allow = ["AGPL-3.0"], name = "serai-processor-transaction-chaining-scheduler" },
{ allow = ["AGPL-3.0"], name = "serai-processor-scanner" },
{ allow = ["AGPL-3.0"], name = "serai-processor" },

View file

@ -0,0 +1,25 @@
[package]
name = "serai-processor-utxo-scheduler-primitives"
version = "0.1.0"
description = "Primitives for UTXO schedulers for the Serai processor"
license = "AGPL-3.0-only"
repository = "https://github.com/serai-dex/serai/tree/develop/processor/scheduler/utxo/primitives"
authors = ["Luke Parker <lukeparker5132@gmail.com>"]
keywords = []
edition = "2021"
publish = false
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[lints]
workspace = true
[dependencies]
async-trait = { version = "0.1", default-features = false }
serai-primitives = { path = "../../../../substrate/primitives", default-features = false, features = ["std"] }
primitives = { package = "serai-processor-primitives", path = "../../../primitives" }
scanner = { package = "serai-processor-scanner", path = "../../../scanner" }

View file

@ -0,0 +1,15 @@
AGPL-3.0-only license
Copyright (c) 2024 Luke Parker
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License Version 3 as
published by the Free Software Foundation.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.

View file

@ -0,0 +1,3 @@
# UTXO Scheduler Primitives
Primitives for UTXO schedulers.

View file

@ -0,0 +1,179 @@
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
use core::fmt::Debug;
use serai_primitives::{Coin, Amount};
use primitives::ReceivedOutput;
use scanner::{Payment, ScannerFeed, AddressFor, OutputFor};
/// An object able to plan a transaction.
#[async_trait::async_trait]
pub trait TransactionPlanner<S: ScannerFeed> {
/// An error encountered when determining the fee rate.
///
/// This MUST be an ephemeral error. Retrying fetching data from the blockchain MUST eventually
/// resolve without manual intervention/changing the arguments.
type EphemeralError: Debug;
/// The type representing a fee rate to use for transactions.
type FeeRate: Clone + Copy;
/// The type representing a planned transaction.
type PlannedTransaction;
/// Obtain the fee rate to pay.
///
/// This must be constant to the finalized block referenced by this block number and the coin.
async fn fee_rate(
&self,
block_number: u64,
coin: Coin,
) -> Result<Self::FeeRate, Self::EphemeralError>;
/// Calculate the for a tansaction with this structure.
///
/// The fee rate, inputs, and payments, will all be for the same coin. The returned fee is
/// denominated in this coin.
fn calculate_fee(
&self,
block_number: u64,
fee_rate: Self::FeeRate,
inputs: Vec<OutputFor<S>>,
payments: Vec<Payment<S>>,
change: Option<AddressFor<S>>,
) -> Amount;
/// Plan a transaction.
///
/// This must only require the same fee as would be returned by `calculate_fee`. The caller is
/// trusted to maintain `sum(inputs) - sum(payments) >= if change.is_some() { DUST } else { 0 }`.
///
/// `change` will always be an address belonging to the Serai network.
fn plan(
&self,
block_number: u64,
fee_rate: Self::FeeRate,
inputs: Vec<OutputFor<S>>,
payments: Vec<Payment<S>>,
change: Option<AddressFor<S>>,
) -> Self::PlannedTransaction;
/// Obtain a PlannedTransaction via amortizing the fee over the payments.
///
/// `operating_costs` is accrued to if Serai faces the burden of a fee or drops inputs not worth
/// accumulating. `operating_costs` will be amortized along with this transaction's fee as
/// possible. Please see `spec/processor/UTXO Management.md` for more information.
///
/// Returns `None` if the fee exceeded the inputs, or `Some` otherwise.
fn plan_transaction_with_fee_amortization(
&self,
operating_costs: &mut u64,
block_number: u64,
fee_rate: Self::FeeRate,
inputs: Vec<OutputFor<S>>,
mut payments: Vec<Payment<S>>,
change: Option<AddressFor<S>>,
) -> Option<Self::PlannedTransaction> {
// 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) >=
payments.iter().map(|payment| payment.balance().amount.0).sum::<u64>(),
"attempted to fulfill payments without a sufficient input set"
);
}
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(block_number, fee_rate, inputs.clone(), payments.clone(), change.clone())
.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 + 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(block_number, fee_rate, inputs.clone(), payments.clone(), change.clone())
.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 += 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 -= amortized;
None?;
}
} else {
// Since we have payments which can pay the fee we ended up with, amortize it
let adjusted_fee = (*operating_costs + 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 + fee));
}
// Update the amount of operating costs
*operating_costs = (*operating_costs + fee).saturating_sub(amortized);
}
// Because we amortized, or accrued as operating costs, the fee, make the transaction
Some(self.plan(block_number, fee_rate, inputs, payments, change))
}
}