Skip to main content

signet_orders/
preflight.rs

1//! Preflight validation checks for orders and fills.
2//!
3//! This module provides a [`Permit2Ext`] trait that extends any
4//! [`Provider`] with methods to validate that orders can be successfully
5//! filled before submitting them to the network. It checks:
6//! - Token balances are sufficient
7//! - ERC20 approvals are in place for Permit2
8//! - Permit2 nonces haven't been consumed
9
10use crate::OrdersAndFills;
11use alloy::{
12    primitives::{Address, U256},
13    providers::Provider,
14};
15use futures_util::future::{try_join, try_join3, try_join_all};
16use signet_types::{SignedOrder, UnsignedOrder};
17use signet_zenith::{IPermit2, IERC20, PERMIT2_ADDRESS};
18use std::future::Future;
19use thiserror::Error;
20
21/// Errors that can occur during preflight validation.
22#[derive(Debug, Error)]
23#[non_exhaustive]
24pub enum PreflightError {
25    /// Provider error occurred while checking conditions.
26    #[error("provider error: {0}")]
27    Provider(#[from] alloy::contract::Error),
28    /// Insufficient token balance for the order.
29    #[error("insufficient balance: have {have}, need {need}")]
30    InsufficientBalance {
31        /// Current balance.
32        have: U256,
33        /// Required balance.
34        need: U256,
35    },
36    /// Insufficient ERC20 allowance for Permit2.
37    #[error("insufficient allowance: have {have}, need {need}")]
38    InsufficientAllowance {
39        /// Current allowance.
40        have: U256,
41        /// Required allowance.
42        need: U256,
43    },
44    /// Permit2 nonce has already been consumed.
45    #[error("nonce already consumed: word_pos={word_pos}, bit_pos={bit_pos}")]
46    NonceConsumed {
47        /// Word position in nonce bitmap.
48        word_pos: U256,
49        /// Bit position in the word.
50        bit_pos: u8,
51    },
52}
53
54/// Extension trait that adds Permit2 preflight validation to any [`Provider`].
55///
56/// Provides low-level checks ([`sufficient_balance`], [`token_approved`],
57/// [`nonce_available`]) and high-level order validation methods.
58///
59/// [`sufficient_balance`]: Permit2Ext::sufficient_balance
60/// [`token_approved`]: Permit2Ext::token_approved
61/// [`nonce_available`]: Permit2Ext::nonce_available
62///
63/// # Example
64///
65/// ```no_run
66/// # async fn example() -> Result<(), signet_orders::PreflightError> {
67/// # let provider = alloy::providers::ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
68/// # let signed_order: signet_types::SignedOrder = unimplemented!();
69/// use signet_orders::Permit2Ext;
70///
71/// provider.check_signed_order(&signed_order).await?;
72/// # Ok(())
73/// # }
74/// ```
75pub trait Permit2Ext: Sync {
76    /// Check if `user` has at least `amount` of `token`.
77    fn sufficient_balance(
78        &self,
79        token: Address,
80        user: Address,
81        amount: U256,
82    ) -> impl Future<Output = Result<(), PreflightError>> + Send;
83
84    /// Check if `user` has approved at least `amount` of `token` to Permit2.
85    fn token_approved(
86        &self,
87        token: Address,
88        user: Address,
89        amount: U256,
90    ) -> impl Future<Output = Result<(), PreflightError>> + Send;
91
92    /// Check if a Permit2 `nonce` is still available (not yet consumed).
93    fn nonce_available(
94        &self,
95        user: Address,
96        nonce: U256,
97    ) -> impl Future<Output = Result<(), PreflightError>> + Send;
98
99    /// Validate all preflight conditions for a [`SignedOrder`].
100    ///
101    /// Checks token balances, ERC20 approvals, and Permit2 nonce for each
102    /// permitted token. Runs all checks concurrently.
103    fn check_signed_order(
104        &self,
105        order: &SignedOrder,
106    ) -> impl Future<Output = Result<(), PreflightError>> + Send {
107        async move {
108            let permit = order.permit();
109            let owner = permit.owner;
110
111            let balance_checks = permit
112                .permit
113                .permitted
114                .iter()
115                .map(|tp| self.sufficient_balance(tp.token, owner, tp.amount));
116            let approval_checks = permit
117                .permit
118                .permitted
119                .iter()
120                .map(|tp| self.token_approved(tp.token, owner, tp.amount));
121
122            try_join3(
123                try_join_all(balance_checks),
124                try_join_all(approval_checks),
125                self.nonce_available(owner, permit.permit.nonce),
126            )
127            .await
128            .map(|_| ())
129        }
130    }
131
132    /// Validate preflight conditions for an [`UnsignedOrder`].
133    ///
134    /// Checks token balances and ERC20 approvals for each input token.
135    /// Nonce check is skipped since unsigned orders lack a finalized nonce.
136    fn check_unsigned_order(
137        &self,
138        order: &UnsignedOrder<'_>,
139        user: Address,
140    ) -> impl Future<Output = Result<(), PreflightError>> + Send {
141        async move {
142            let balance_checks = order
143                .inputs()
144                .iter()
145                .map(|input| self.sufficient_balance(input.token, user, input.amount));
146            let approval_checks = order
147                .inputs()
148                .iter()
149                .map(|input| self.token_approved(input.token, user, input.amount));
150
151            try_join(try_join_all(balance_checks), try_join_all(approval_checks)).await.map(|_| ())
152        }
153    }
154
155    /// Validate preflight conditions for all orders in an [`OrdersAndFills`].
156    ///
157    /// Runs [`check_signed_order`] for every order concurrently.
158    ///
159    /// [`check_signed_order`]: Permit2Ext::check_signed_order
160    fn check_orders_and_fills(
161        &self,
162        orders_and_fills: &OrdersAndFills,
163    ) -> impl Future<Output = Result<(), PreflightError>> + Send {
164        async move {
165            try_join_all(
166                orders_and_fills.orders().iter().map(|order| self.check_signed_order(order)),
167            )
168            .await
169            .map(|_| ())
170        }
171    }
172}
173
174impl<P: Provider> Permit2Ext for P {
175    async fn sufficient_balance(
176        &self,
177        token: Address,
178        user: Address,
179        amount: U256,
180    ) -> Result<(), PreflightError> {
181        let balance = IERC20::new(token, self).balanceOf(user).call().await?;
182        (balance >= amount)
183            .then_some(())
184            .ok_or(PreflightError::InsufficientBalance { have: balance, need: amount })
185    }
186
187    async fn token_approved(
188        &self,
189        token: Address,
190        user: Address,
191        amount: U256,
192    ) -> Result<(), PreflightError> {
193        let allowance = IERC20::new(token, self).allowance(user, PERMIT2_ADDRESS).call().await?;
194        (allowance >= amount)
195            .then_some(())
196            .ok_or(PreflightError::InsufficientAllowance { have: allowance, need: amount })
197    }
198
199    async fn nonce_available(&self, user: Address, nonce: U256) -> Result<(), PreflightError> {
200        let permit2 = IPermit2::new(PERMIT2_ADDRESS, self);
201        let (word_pos, bit_pos) = permit2.nonce_to_bitmap_position(nonce);
202        let bitmap = permit2.nonceBitmap(user, word_pos).call().await?;
203        (bitmap & (U256::from(1) << bit_pos) == U256::ZERO)
204            .then_some(())
205            .ok_or(PreflightError::NonceConsumed { word_pos, bit_pos })
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::PreflightError;
212    use alloy::primitives::uint;
213    use signet_zenith::PERMIT2_ADDRESS;
214
215    #[test]
216    fn test_preflight_errors() {
217        let insufficient_balance =
218            PreflightError::InsufficientBalance { have: uint!(100_U256), need: uint!(200_U256) };
219        assert!(insufficient_balance.to_string().contains("insufficient balance"));
220        assert!(insufficient_balance.to_string().contains("100"));
221        assert!(insufficient_balance.to_string().contains("200"));
222
223        let insufficient_allowance =
224            PreflightError::InsufficientAllowance { have: uint!(50_U256), need: uint!(100_U256) };
225        assert!(insufficient_allowance.to_string().contains("insufficient allowance"));
226
227        let nonce_consumed = PreflightError::NonceConsumed { word_pos: uint!(1_U256), bit_pos: 42 };
228        assert!(nonce_consumed.to_string().contains("nonce already consumed"));
229        assert!(nonce_consumed.to_string().contains("word_pos=1"));
230        assert!(nonce_consumed.to_string().contains("bit_pos=42"));
231    }
232
233    #[test]
234    fn test_permit2_address_matches_types() {
235        assert_eq!(
236            PERMIT2_ADDRESS,
237            alloy::primitives::address!("0x000000000022D473030F116dDEE9F6B43aC78BA3")
238        );
239    }
240}