diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 070c5b58..33f2e852 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 \ diff --git a/Cargo.lock b/Cargo.lock index 2a9de4b9..935e95d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 27e5e562..17435713 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/deny.toml b/deny.toml index 7531f3b7..fb616244 100644 --- a/deny.toml +++ b/deny.toml @@ -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" }, diff --git a/processor/scheduler/utxo/primitives/Cargo.toml b/processor/scheduler/utxo/primitives/Cargo.toml new file mode 100644 index 00000000..01d3db7d --- /dev/null +++ b/processor/scheduler/utxo/primitives/Cargo.toml @@ -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 "] +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" } diff --git a/processor/scheduler/utxo/primitives/LICENSE b/processor/scheduler/utxo/primitives/LICENSE new file mode 100644 index 00000000..e091b149 --- /dev/null +++ b/processor/scheduler/utxo/primitives/LICENSE @@ -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 . diff --git a/processor/scheduler/utxo/primitives/README.md b/processor/scheduler/utxo/primitives/README.md new file mode 100644 index 00000000..81bc954a --- /dev/null +++ b/processor/scheduler/utxo/primitives/README.md @@ -0,0 +1,3 @@ +# UTXO Scheduler Primitives + +Primitives for UTXO schedulers. diff --git a/processor/scheduler/utxo/primitives/src/lib.rs b/processor/scheduler/utxo/primitives/src/lib.rs new file mode 100644 index 00000000..61dd9d88 --- /dev/null +++ b/processor/scheduler/utxo/primitives/src/lib.rs @@ -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 { + /// 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; + + /// 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>, + payments: Vec>, + change: Option>, + ) -> 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>, + payments: Vec>, + change: Option>, + ) -> 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>, + mut payments: Vec>, + change: Option>, + ) -> Option { + // 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::() + *operating_costs) >= + payments.iter().map(|payment| payment.balance().amount.0).sum::(), + "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::(); + // 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)) + } +} diff --git a/processor/scheduler/transaction-chaining/Cargo.toml b/processor/scheduler/utxo/transaction-chaining/Cargo.toml similarity index 100% rename from processor/scheduler/transaction-chaining/Cargo.toml rename to processor/scheduler/utxo/transaction-chaining/Cargo.toml diff --git a/processor/scheduler/transaction-chaining/LICENSE b/processor/scheduler/utxo/transaction-chaining/LICENSE similarity index 100% rename from processor/scheduler/transaction-chaining/LICENSE rename to processor/scheduler/utxo/transaction-chaining/LICENSE diff --git a/processor/scheduler/transaction-chaining/README.md b/processor/scheduler/utxo/transaction-chaining/README.md similarity index 100% rename from processor/scheduler/transaction-chaining/README.md rename to processor/scheduler/utxo/transaction-chaining/README.md diff --git a/processor/scheduler/transaction-chaining/src/lib.rs b/processor/scheduler/utxo/transaction-chaining/src/lib.rs similarity index 100% rename from processor/scheduler/transaction-chaining/src/lib.rs rename to processor/scheduler/utxo/transaction-chaining/src/lib.rs