mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-08 12:19:24 +00:00
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:
1
.github/workflows/tests.yml
vendored
1
.github/workflows/tests.yml
vendored
@@ -42,6 +42,7 @@ jobs:
|
|||||||
-p serai-processor-key-gen \
|
-p serai-processor-key-gen \
|
||||||
-p serai-processor-frost-attempt-manager \
|
-p serai-processor-frost-attempt-manager \
|
||||||
-p serai-processor-primitives \
|
-p serai-processor-primitives \
|
||||||
|
-p serai-processor-utxo-scheduler-primitives \
|
||||||
-p serai-processor-transaction-chaining-scheduler \
|
-p serai-processor-transaction-chaining-scheduler \
|
||||||
-p serai-processor-scanner \
|
-p serai-processor-scanner \
|
||||||
-p serai-processor \
|
-p serai-processor \
|
||||||
|
|||||||
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -8709,6 +8709,20 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "serai-reproducible-runtime-tests"
|
name = "serai-reproducible-runtime-tests"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ members = [
|
|||||||
"processor/frost-attempt-manager",
|
"processor/frost-attempt-manager",
|
||||||
|
|
||||||
"processor/primitives",
|
"processor/primitives",
|
||||||
"processor/scheduler/transaction-chaining",
|
"processor/scheduler/utxo/primitives",
|
||||||
|
"processor/scheduler/utxo/transaction-chaining",
|
||||||
"processor/scanner",
|
"processor/scanner",
|
||||||
"processor",
|
"processor",
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ exceptions = [
|
|||||||
{ allow = ["AGPL-3.0"], name = "serai-processor-key-gen" },
|
{ 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-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-transaction-chaining-scheduler" },
|
||||||
{ allow = ["AGPL-3.0"], name = "serai-processor-scanner" },
|
{ allow = ["AGPL-3.0"], name = "serai-processor-scanner" },
|
||||||
{ allow = ["AGPL-3.0"], name = "serai-processor" },
|
{ allow = ["AGPL-3.0"], name = "serai-processor" },
|
||||||
|
|||||||
25
processor/scheduler/utxo/primitives/Cargo.toml
Normal file
25
processor/scheduler/utxo/primitives/Cargo.toml
Normal 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" }
|
||||||
15
processor/scheduler/utxo/primitives/LICENSE
Normal file
15
processor/scheduler/utxo/primitives/LICENSE
Normal 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/>.
|
||||||
3
processor/scheduler/utxo/primitives/README.md
Normal file
3
processor/scheduler/utxo/primitives/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# UTXO Scheduler Primitives
|
||||||
|
|
||||||
|
Primitives for UTXO schedulers.
|
||||||
179
processor/scheduler/utxo/primitives/src/lib.rs
Normal file
179
processor/scheduler/utxo/primitives/src/lib.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user