Skip to main content

nautilus_blockchain/execution/
preflight.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Structured preflight readiness report for execution operations.
17
18use alloy::primitives::{Address, U256};
19use nautilus_model::identifiers::InstrumentId;
20
21/// Deployed-bytecode check for a single contract address.
22#[derive(Debug, Clone)]
23pub struct ContractCodeCheck {
24    /// The checked address.
25    pub address: Address,
26    /// Whether non-empty bytecode is deployed at the address.
27    pub has_deployed_code: bool,
28}
29
30/// Preflight check for the selected pool.
31#[derive(Debug, Clone)]
32pub struct PoolPreflightCheck {
33    /// The instrument ID the pool was selected by.
34    pub instrument_id: InstrumentId,
35    /// The pool address.
36    pub address: Address,
37    /// Whether non-empty bytecode is deployed at the pool address.
38    pub has_deployed_code: bool,
39    /// The pool fee tier (hundredths of a bip), required for swap calldata.
40    pub fee: Option<u32>,
41    /// The resolved base (input) token address.
42    pub base_token: Address,
43    /// The resolved quote (output) token address.
44    pub quote_token: Address,
45}
46
47/// Preflight check for a pool token, including wallet balance and router allowances.
48#[derive(Debug, Clone)]
49pub struct TokenPreflightCheck {
50    /// The token address.
51    pub address: Address,
52    /// The token symbol.
53    pub symbol: String,
54    /// Whether non-empty bytecode is deployed at the token address.
55    pub has_deployed_code: bool,
56    /// The wallet's raw token balance.
57    pub wallet_balance: U256,
58    /// Exact allowances the wallet has granted each allowlisted router.
59    pub router_allowances: Vec<(Address, U256)>,
60}
61
62/// Structured, sanitized result of an execution preflight.
63///
64/// Contains no RPC URLs, keys, or raw signed transactions.
65#[derive(Debug, Clone)]
66pub struct BlockchainPreflightReport {
67    /// The configured expected chain ID.
68    pub expected_chain_id: u64,
69    /// The chain ID reported by the RPC node.
70    pub actual_chain_id: u64,
71    /// Whether the reported chain ID matches the configuration.
72    pub chain_id_matches: bool,
73    /// The pool check.
74    pub pool: PoolPreflightCheck,
75    /// Deployed-bytecode checks for each allowlisted router.
76    pub routers: Vec<ContractCodeCheck>,
77    /// Checks for the pool's base and quote tokens.
78    pub tokens: Vec<TokenPreflightCheck>,
79    /// The wallet's raw native currency balance in wei.
80    pub native_balance_wei: U256,
81    /// The latest base fee per gas in wei.
82    pub base_fee_per_gas_wei: u128,
83    /// The node's suggested max priority fee per gas in wei.
84    pub max_priority_fee_per_gas_wei: u128,
85    /// The derived max fee per gas in wei after the configured base fee buffer.
86    pub derived_max_fee_per_gas_wei: u128,
87    /// Whether the derived max fee is within the configured ceiling.
88    pub fee_within_ceiling: bool,
89    /// Whether every check passed and the wallet is ready for execution operations.
90    pub ready: bool,
91    /// Human-readable descriptions of every failed check.
92    pub issues: Vec<String>,
93}
94
95impl BlockchainPreflightReport {
96    /// Builds a report from preflight check inputs, deriving `ready` and `issues`.
97    #[must_use]
98    #[expect(clippy::too_many_arguments)]
99    pub fn new(
100        expected_chain_id: u64,
101        actual_chain_id: u64,
102        pool: PoolPreflightCheck,
103        routers: Vec<ContractCodeCheck>,
104        tokens: Vec<TokenPreflightCheck>,
105        native_balance_wei: U256,
106        base_fee_per_gas_wei: u128,
107        max_priority_fee_per_gas_wei: u128,
108        derived_max_fee_per_gas_wei: u128,
109        max_fee_per_gas_wei: u128,
110    ) -> Self {
111        let chain_id_matches = expected_chain_id == actual_chain_id;
112        let fee_within_ceiling = derived_max_fee_per_gas_wei <= max_fee_per_gas_wei;
113
114        let mut issues = Vec::new();
115
116        if !chain_id_matches {
117            issues.push(format!(
118                "Chain ID mismatch: expected {expected_chain_id}, node reported {actual_chain_id}"
119            ));
120        }
121
122        if !pool.has_deployed_code {
123            issues.push(format!(
124                "No deployed bytecode at pool address {}",
125                pool.address
126            ));
127        }
128
129        if pool.fee.is_none() {
130            issues.push("Pool fee tier is missing".to_string());
131        }
132
133        for router in &routers {
134            if !router.has_deployed_code {
135                issues.push(format!(
136                    "No deployed bytecode at router address {}",
137                    router.address
138                ));
139            }
140        }
141
142        for token in &tokens {
143            if !token.has_deployed_code {
144                issues.push(format!(
145                    "No deployed bytecode at {} token address {}",
146                    token.symbol, token.address
147                ));
148            }
149        }
150
151        if native_balance_wei.is_zero() {
152            issues.push("Native currency balance is zero; cannot pay gas".to_string());
153        }
154
155        let base_token_check = tokens.iter().find(|t| t.address == pool.base_token);
156        let input_balance_ok = base_token_check.is_some_and(|t| !t.wallet_balance.is_zero());
157
158        if !input_balance_ok {
159            issues.push(format!("Input token {} balance is zero", pool.base_token));
160        }
161
162        let execution_router = routers.first().map(|router| router.address);
163        let allowance_ok = base_token_check.is_some_and(|token| {
164            token
165                .router_allowances
166                .iter()
167                .any(|(router, amount)| Some(*router) == execution_router && !amount.is_zero())
168        });
169
170        if !allowance_ok {
171            issues.push(format!(
172                "No router allowance for input token {}",
173                pool.base_token
174            ));
175        }
176
177        if !fee_within_ceiling {
178            issues.push(format!(
179                "Derived max fee per gas {derived_max_fee_per_gas_wei} wei exceeds ceiling {max_fee_per_gas_wei} wei"
180            ));
181        }
182
183        let ready = issues.is_empty();
184
185        Self {
186            expected_chain_id,
187            actual_chain_id,
188            chain_id_matches,
189            pool,
190            routers,
191            tokens,
192            native_balance_wei,
193            base_fee_per_gas_wei,
194            max_priority_fee_per_gas_wei,
195            derived_max_fee_per_gas_wei,
196            fee_within_ceiling,
197            ready,
198            issues,
199        }
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use alloy::primitives::{U256, address};
206    use rstest::rstest;
207
208    use super::*;
209
210    #[rstest]
211    fn report_requires_allowance_on_execution_router() {
212        let base_token = address!("1111111111111111111111111111111111111111");
213        let quote_token = address!("2222222222222222222222222222222222222222");
214        let execution_router = address!("3333333333333333333333333333333333333333");
215        let alternate_router = address!("4444444444444444444444444444444444444444");
216        let report = BlockchainPreflightReport::new(
217            42161,
218            42161,
219            PoolPreflightCheck {
220                instrument_id: "0x5555555555555555555555555555555555555555.Arbitrum:UniswapV3"
221                    .parse()
222                    .unwrap(),
223                address: address!("5555555555555555555555555555555555555555"),
224                has_deployed_code: true,
225                fee: Some(500),
226                base_token,
227                quote_token,
228            },
229            vec![
230                ContractCodeCheck {
231                    address: execution_router,
232                    has_deployed_code: true,
233                },
234                ContractCodeCheck {
235                    address: alternate_router,
236                    has_deployed_code: true,
237                },
238            ],
239            vec![
240                TokenPreflightCheck {
241                    address: base_token,
242                    symbol: "BASE".to_string(),
243                    has_deployed_code: true,
244                    wallet_balance: U256::from(1),
245                    router_allowances: vec![
246                        (execution_router, U256::ZERO),
247                        (alternate_router, U256::from(1)),
248                    ],
249                },
250                TokenPreflightCheck {
251                    address: quote_token,
252                    symbol: "QUOTE".to_string(),
253                    has_deployed_code: true,
254                    wallet_balance: U256::ZERO,
255                    router_allowances: Vec::new(),
256                },
257            ],
258            U256::from(1),
259            100_000_000,
260            10_000_000,
261            130_000_000,
262            1_000_000_000,
263        );
264
265        assert!(!report.ready);
266        assert_eq!(
267            report.issues,
268            vec![format!("No router allowance for input token {base_token}")]
269        );
270    }
271}