diff --git a/crates/orders/Cargo.toml b/crates/orders/Cargo.toml index 1683482..7311081 100644 --- a/crates/orders/Cargo.toml +++ b/crates/orders/Cargo.toml @@ -21,5 +21,7 @@ signet-types.workspace = true signet-zenith.workspace = true alloy.workspace = true +chrono.workspace = true futures-util.workspace = true thiserror.workspace = true +tracing.workspace = true diff --git a/crates/orders/src/fee_policy.rs b/crates/orders/src/fee_policy.rs new file mode 100644 index 0000000..6079b57 --- /dev/null +++ b/crates/orders/src/fee_policy.rs @@ -0,0 +1,177 @@ +use crate::{BundleSubmitter, FillSubmitter, OrdersAndFills, TxBuilder}; +use alloy::primitives::Address; +use alloy::{ + eips::eip2718::Encodable2718, + network::{Ethereum, Network, TransactionBuilder}, + primitives::Bytes, + providers::{fillers::FillerControlFlow, SendableTx}, + rpc::types::mev::EthSendBundle, + transports::{RpcError, TransportErrorKind}, +}; +use futures_util::{stream, StreamExt, TryStreamExt}; +use signet_bundle::SignetEthBundle; +use signet_constants::SignetSystemConstants; +#[cfg(doc)] +use signet_types::SignedFill; +use tracing::{error, instrument}; + +/// Errors returned by [`FeePolicySubmitter`]. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum FeePolicyError { + /// No fills provided for submission. + #[error("no fills provided for submission")] + NoFills, + /// RPC call failed. + #[error("RPC error: {0}")] + Rpc(#[source] RpcError), + /// Transaction is incomplete (missing required properties). + #[error("transaction missing required properties: {0:?}")] + IncompleteTransaction(Vec<(&'static str, Vec<&'static str>)>), + /// Bundle submission failed. + #[error("failed to submit bundle: {0}")] + Submission(#[source] Box), +} + +impl From for FeePolicyError { + fn from(filler_control_flow: FillerControlFlow) -> Self { + match filler_control_flow { + FillerControlFlow::Missing(missing) => Self::IncompleteTransaction(missing), + FillerControlFlow::Finished | FillerControlFlow::Ready => { + error!("fill returned Builder but status is {filler_control_flow:?}"); + Self::IncompleteTransaction(Vec::new()) + } + } + } +} + +/// A [`FillSubmitter`] that wraps a [`BundleSubmitter`] and handles fee policy. +/// +/// This submitter converts [`SignedFill`]s into transactions with appropriate gas pricing, builds +/// a [`SignetEthBundle`], and submits via the wrapped submitter. +/// +/// The providers must be configured with appropriate fillers for gas, nonce, chain ID, and wallet +/// signing (e.g., via `ProviderBuilder::with_gas_estimation()` and `ProviderBuilder::wallet()`). +/// Note that the provider's nonce filler must correctly increment nonces across all transactions +/// built within a single [`FillSubmitter::submit_fills`] call. +#[derive(Debug, Clone)] +pub struct FeePolicySubmitter { + ru_provider: RuP, + host_provider: HostP, + submitter: B, + constants: SignetSystemConstants, +} + +impl FeePolicySubmitter { + /// Create a new `FeePolicySubmitter`. + pub const fn new( + ru_provider: RuP, + host_provider: HostP, + submitter: B, + constants: SignetSystemConstants, + ) -> Self { + Self { ru_provider, host_provider, submitter, constants } + } + + /// Get a reference to the rollup provider. + pub const fn ru_provider(&self) -> &RuP { + &self.ru_provider + } + + /// Get a reference to the host provider. + pub const fn host_provider(&self) -> &HostP { + &self.host_provider + } + + /// Get a reference to the inner submitter. + pub const fn submitter(&self) -> &B { + &self.submitter + } + + /// Get a reference to the system constants. + pub const fn constants(&self) -> &SignetSystemConstants { + &self.constants + } +} + +impl FillSubmitter for FeePolicySubmitter +where + RuP: TxBuilder, + HostP: TxBuilder, + B: BundleSubmitter + Send + Sync, +{ + type Response = B::Response; + type Error = FeePolicyError; + + #[instrument(skip_all, fields(order_count = orders.len(), fill_count = fills.len()))] + async fn submit_fills( + &self, + OrdersAndFills { orders, fills, signer_address }: OrdersAndFills, + ) -> Result { + if fills.is_empty() { + return Err(FeePolicyError::NoFills); + } + + // Build rollup transaction requests: fill (if present, must come first) then initiates + let fill_iter = fills + .get(&self.constants.ru_chain_id()) + .map(|fill| fill.to_fill_tx(self.constants.ru_orders())) + .into_iter(); + let order_iter = orders + .iter() + .map(|order| order.to_initiate_tx(signer_address, self.constants.ru_orders())); + let rollup_txs: Vec = stream::iter(fill_iter.chain(order_iter)) + .then(|tx_request| sign_and_encode_tx(&self.ru_provider, tx_request, signer_address)) + .try_collect() + .await?; + + // Build host transaction request: fill only (if present) + let host_txs = match fills.get(&self.constants.host_chain_id()) { + Some(fill) => { + let tx_request = fill.to_fill_tx(self.constants.host_orders()); + vec![sign_and_encode_tx(&self.host_provider, tx_request, signer_address).await?] + } + None => vec![], + }; + + // NOTE: We could retrieve a header up front, then use number+1. We could also check that + // the timestamp in the orders are valid for current.timestamp + calculator.slot_duration. + let target_block = + self.ru_provider.get_block_number().await.map_err(FeePolicyError::Rpc)? + 1; + + let bundle = SignetEthBundle::new( + EthSendBundle { txs: rollup_txs, block_number: target_block, ..Default::default() }, + host_txs, + ); + + self.submitter + .submit_bundle(bundle) + .await + .map_err(|error| FeePolicyError::Submission(Box::new(error))) + } +} + +/// Sign and encode a transaction request for inclusion in a bundle. +#[instrument(skip_all)] +async fn sign_and_encode_tx( + provider: &P, + mut tx_request: N::TransactionRequest, + signer_address: Address, +) -> Result +where + N: Network, + P: TxBuilder, + N::TxEnvelope: Encodable2718, +{ + tx_request = tx_request.with_from(signer_address); + let sendable = provider.fill(tx_request).await.map_err(FeePolicyError::Rpc)?; + + let envelope = match sendable { + SendableTx::Envelope(envelope) => envelope, + SendableTx::Builder(tx) => { + return Err(FeePolicyError::from(provider.status(&tx))); + } + }; + + Ok(Bytes::from(envelope.encoded_2718())) +} diff --git a/crates/orders/src/filler.rs b/crates/orders/src/filler.rs new file mode 100644 index 0000000..1f3dd71 --- /dev/null +++ b/crates/orders/src/filler.rs @@ -0,0 +1,187 @@ +use crate::{FillSubmitter, OrderSource}; +use alloy::{primitives::Address, signers::Signer}; +use chrono::Utc; +use futures_util::{Stream, StreamExt}; +use signet_constants::SignetSystemConstants; +use signet_types::{SignedFill, SignedOrder, SigningError, UnsignedFill}; +use std::collections::HashMap; +use tracing::instrument; + +/// Errors returned by [`Filler`]. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum FillerError { + /// Order source error. + #[error("failed to get orders: {0}")] + Source(#[source] Box), + /// No orders to fill. + #[error("no orders to fill")] + NoOrders, + /// Failed to sign fills for orders. + #[error("failed to sign fills: {0}")] + Signing(#[from] SigningError), + /// Fill submission failed. + #[error("failed to submit fills: {0}")] + Submission(#[source] Box), +} + +/// Options for configuring the [`Filler`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct FillerOptions { + /// Optional deadline offset in seconds for fills. + pub deadline_offset: Option, + /// Optional nonce to use for permit2 signatures. + pub nonce: Option, +} + +impl FillerOptions { + /// Create a new [`FillerOptions`] with default values. + pub const fn new() -> Self { + Self { deadline_offset: None, nonce: None } + } + + /// Set the deadline offset. + pub const fn with_deadline_offset(mut self, offset: u64) -> Self { + self.deadline_offset = Some(offset); + self + } + + /// Set the nonce. + pub const fn with_nonce(mut self, nonce: u64) -> Self { + self.nonce = Some(nonce); + self + } +} + +/// A small struct to ensure the relevant orders remain paired with the fills generated from them +/// and with the signer's address. +#[derive(Debug)] +pub struct OrdersAndFills { + pub(crate) orders: Vec, + pub(crate) fills: HashMap, + pub(crate) signer_address: Address, +} + +/// Fills orders by fetching from a source, signing fills, and submitting them. +/// +/// `Filler` is generic over: +/// - `Sign`: A [`Signer`] for signing fills +/// - `Source`: An [`OrderSource`] for fetching orders +/// - `Submit`: A [`FillSubmitter`] for submitting signed fills +#[derive(Debug, Clone)] +pub struct Filler { + signer: Sign, + order_source: Source, + submitter: Submit, + constants: SignetSystemConstants, + options: FillerOptions, +} + +impl Filler { + /// Create a new filler instance. + pub const fn new( + signer: Sign, + order_source: Source, + submitter: Submit, + constants: SignetSystemConstants, + options: FillerOptions, + ) -> Self { + Self { signer, order_source, submitter, constants, options } + } + + /// Get a reference to the signer. + pub const fn signer(&self) -> &Sign { + &self.signer + } + + /// Get a reference to the order source. + pub const fn order_source(&self) -> &Source { + &self.order_source + } + + /// Get a reference to the submitter. + pub const fn submitter(&self) -> &Submit { + &self.submitter + } + + /// Get a reference to the system constants. + pub const fn constants(&self) -> &SignetSystemConstants { + &self.constants + } + + /// Get a reference to the filler options. + pub const fn options(&self) -> &FillerOptions { + &self.options + } +} + +impl Filler +where + Source: OrderSource + Send + Sync, +{ + /// Query the source for signed orders. + pub fn get_orders( + &self, + ) -> impl Stream> + Send + use<'_, Sign, Source, Submit> + { + self.order_source + .get_orders() + .map(|result| result.map_err(|e| FillerError::Source(Box::new(e)))) + } +} + +impl Filler +where + Sign: Signer + Send + Sync, +{ + /// Sign fills for the given orders. + /// + /// Returns a map of chain ID to signed fill for each target chain. + pub async fn sign_fills( + &self, + orders: Vec, + ) -> Result { + let mut unsigned_fill = UnsignedFill::new().with_chain(self.constants.clone()); + + if let Some(deadline_offset) = self.options.deadline_offset { + let deadline = Utc::now().timestamp() as u64 + deadline_offset; + unsigned_fill = unsigned_fill.with_deadline(deadline); + } + + if let Some(nonce) = self.options.nonce { + unsigned_fill = unsigned_fill.with_nonce(nonce); + } + + for order in &orders { + unsigned_fill = unsigned_fill.fill(order); + } + + let fills = unsigned_fill.sign(&self.signer).await?; + let signer_address = self.signer.address(); + Ok(OrdersAndFills { orders, fills, signer_address }) + } +} + +impl Filler +where + Sign: Signer + Send + Sync, + Submit: FillSubmitter + Send + Sync, +{ + /// Fill one or more orders. + /// + /// Signs fills for all orders and submits them via the [`FillSubmitter`]. + /// + /// Returns an error if `orders` is empty, or if signing or submission fails. + #[instrument(skip_all, fields(order_count = orders.len()))] + pub async fn fill(&self, orders: Vec) -> Result { + if orders.is_empty() { + return Err(FillerError::NoOrders); + } + + let orders_and_fills = self.sign_fills(orders).await?; + self.submitter + .submit_fills(orders_and_fills) + .await + .map_err(|error| FillerError::Submission(Box::new(error))) + } +} diff --git a/crates/orders/src/impls/fill_provider.rs b/crates/orders/src/impls/fill_provider.rs new file mode 100644 index 0000000..5ef59dd --- /dev/null +++ b/crates/orders/src/impls/fill_provider.rs @@ -0,0 +1,24 @@ +use crate::TxBuilder; +use alloy::{ + network::Network, + providers::{ + fillers::{FillProvider, FillerControlFlow, TxFiller}, + Provider, SendableTx, + }, + transports::TransportResult, +}; + +impl TxBuilder for FillProvider +where + F: TxFiller, + P: Provider, + N: Network, +{ + async fn fill(&self, tx: N::TransactionRequest) -> TransportResult> { + FillProvider::fill(self, tx).await + } + + fn status(&self, tx: &N::TransactionRequest) -> FillerControlFlow { + self.filler().status(tx) + } +} diff --git a/crates/orders/src/impls/mod.rs b/crates/orders/src/impls/mod.rs index c0e5a22..0edcadc 100644 --- a/crates/orders/src/impls/mod.rs +++ b/crates/orders/src/impls/mod.rs @@ -1 +1,2 @@ +mod fill_provider; mod tx_cache; diff --git a/crates/orders/src/lib.rs b/crates/orders/src/lib.rs index 002c02c..5167db3 100644 --- a/crates/orders/src/lib.rs +++ b/crates/orders/src/lib.rs @@ -16,8 +16,14 @@ mod impls; +mod fee_policy; +pub use fee_policy::{FeePolicyError, FeePolicySubmitter}; + +mod filler; +pub use filler::{Filler, FillerError, FillerOptions, OrdersAndFills}; + mod order_sender; pub use order_sender::{OrderSender, OrderSenderError}; mod traits; -pub use traits::{BundleSubmitter, OrderSource, OrderSubmitter}; +pub use traits::{BundleSubmitter, FillSubmitter, OrderSource, OrderSubmitter, TxBuilder}; diff --git a/crates/orders/src/order_sender.rs b/crates/orders/src/order_sender.rs index 39a5944..f7f8974 100644 --- a/crates/orders/src/order_sender.rs +++ b/crates/orders/src/order_sender.rs @@ -9,10 +9,10 @@ use signet_zenith::RollupOrders::Order; #[non_exhaustive] pub enum OrderSenderError { /// Order signing failed. - #[error("order signing error: {0}")] + #[error("failed to sign order: {0}")] Signing(#[from] SigningError), /// Order submission failed. - #[error("order submission error: {0}")] + #[error("failed to submit order: {0}")] Submission(#[source] Box), } diff --git a/crates/orders/src/traits.rs b/crates/orders/src/traits.rs index ec39646..6b05e96 100644 --- a/crates/orders/src/traits.rs +++ b/crates/orders/src/traits.rs @@ -1,6 +1,18 @@ +#[cfg(doc)] +use crate::Filler; +use crate::OrdersAndFills; +#[cfg(doc)] +use alloy::providers::fillers::FillProvider; +use alloy::{ + network::{Ethereum, Network}, + providers::{fillers::FillerControlFlow, Provider, SendableTx}, + transports::TransportResult, +}; use core::future::Future; use futures_util::Stream; use signet_bundle::SignetEthBundle; +#[cfg(doc)] +use signet_types::SignedFill; use signet_types::SignedOrder; /// A trait for submitting signed orders to a backend. @@ -23,7 +35,7 @@ pub trait OrderSubmitter { /// Implementors of this trait provide access to signed orders, typically from a transaction cache. pub trait OrderSource { /// The error type returned by the stream. - type Error; + type Error: core::error::Error + Send + Sync + 'static; /// Fetch orders from the source as a stream. /// @@ -40,7 +52,7 @@ pub trait BundleSubmitter { /// The response type returned on successful submission. type Response; /// The error type returned by submission operations. - type Error; + type Error: core::error::Error + Send + Sync + 'static; /// Submit a bundle to the backend. fn submit_bundle( @@ -48,3 +60,36 @@ pub trait BundleSubmitter { bundle: SignetEthBundle, ) -> impl Future> + Send; } + +/// A provider that can fill transactions. +/// +/// This trait abstracts over [`FillProvider`] to allow filling transaction requests. +pub trait TxBuilder: Provider + Send + Sync { + /// Fill a transaction request, returning a sendable transaction. + fn fill( + &self, + tx: N::TransactionRequest, + ) -> impl Future>> + Send; + + /// Return the filler's status for the given transaction request. + fn status(&self, tx: &N::TransactionRequest) -> FillerControlFlow; +} + +/// A trait for submitting signed fills to a backend. +/// +/// Implementors handle transaction construction, gas pricing, and target block determination. +/// This decouples the [`Filler`] from provider and fee concerns. +pub trait FillSubmitter { + /// The response type returned on successful submission. + type Response; + /// The error type returned by submission operations. + type Error: core::error::Error + Send + Sync + 'static; + + /// Submit signed fills to the backend. + /// + /// The fills map contains one [`SignedFill`] per destination chain ID. + fn submit_fills( + &self, + orders_and_fills: OrdersAndFills, + ) -> impl Future> + Send; +}