mirror of
https://github.com/serai-dex/serai.git
synced 2025-03-12 09:26:51 +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:
parent
fc765bb9e0
commit
bd277e7032
12 changed files with 240 additions and 1 deletions
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-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
14
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
||||
|
|
|
@ -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" },
|
||||
|
|
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))
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue