1use std::{
17 collections::{HashMap, HashSet},
18 fmt::Debug,
19 ops::RangeInclusive,
20 str::FromStr,
21 sync::Arc,
22 time::{Duration, SystemTime, UNIX_EPOCH},
23};
24
25use alloy::{
26 primitives::{
27 Address, B256, Bytes, I256, U256,
28 aliases::{U24, U160},
29 keccak256,
30 },
31 signers::local::PrivateKeySigner,
32 sol_types::SolCall,
33};
34use anyhow::Context;
35use async_trait::async_trait;
36use nautilus_common::{
37 clients::ExecutionClient,
38 live::runner::get_exec_event_sender,
39 messages::execution::{
40 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
41 GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
42 ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
43 },
44};
45use nautilus_core::{
46 Params, UUID4, UnixNanos, datetime::NANOSECONDS_IN_SECOND, hex, time::get_atomic_clock_realtime,
47};
48use nautilus_live::{
49 ExecutionClientCore, ExecutionEventEmitter,
50 task::{TaskGroup, TaskGroupGuard},
51};
52use nautilus_model::{
53 accounts::AccountAny,
54 defi::{
55 DexType, Pool, PoolIdentifier, SharedChain, Token,
56 data::block::{BLOCK_SCOPED_SNAPSHOT_INDEX, BlockPosition},
57 pool_analysis::quote::SwapQuote,
58 validation::validate_address,
59 wallet::{TokenBalance, WalletBalance},
60 },
61 enums::{CurrencyType, LiquiditySide, OmsType, OrderSide, OrderStatus, OrderType},
62 events::{OrderCanceled, OrderDeniedReason, OrderEventAny, OrderFilled, OrderRejected},
63 identifiers::{AccountId, ClientId, ClientOrderId, InstrumentId, TradeId, Venue, VenueOrderId},
64 orders::{Order, OrderAny},
65 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
66 types::{
67 AccountBalance, Currency, MarginBalance, Money, Price, Quantity, fixed::FIXED_PRECISION,
68 },
69};
70use parking_lot::Mutex;
71use zeroize::Zeroizing;
72
73use crate::{
74 cache::{
75 BlockchainCache,
76 database::{
77 BlockchainCacheDatabase, ExecutionFinalityTransition, ExecutionNonceAssignment,
78 ExecutionPayloadCheck, ExecutionPayloadLease, ExecutionReplacementScan,
79 ExecutionVerificationBatch, ExecutionVerificationBootstrap,
80 ExecutionVerificationDecision, ExecutionVerificationMigration,
81 ExecutionVerificationMigrationRecord, ExecutionVerificationMigrationSnapshot,
82 ExecutionVerifiedHeader, reservation_failure_proven_not_committed,
83 },
84 rows::{ExecutionIntentInsert, ExecutionIntentRow, ExecutionTransactionHashRow},
85 },
86 config::{
87 BlockchainContractRole, BlockchainDeploymentManifest, BlockchainExecutionClientConfig,
88 },
89 contracts::{
90 erc20::{ERC20, Erc20Contract},
91 uniswap_v3_quote::UniswapV3Quote,
92 uniswap_v3_swap::{UniswapV3Factory, UniswapV3RouterState, UniswapV3SwapRouter},
93 weth::WETH9,
94 },
95 execution::{
96 preflight::{
97 BlockchainPreflightReport, ContractCodeCheck, PoolPreflightCheck, TokenPreflightCheck,
98 },
99 sealing::{
100 PayloadKeySet, PayloadPolicy, authenticate_payload, authenticate_payload_identity,
101 authenticate_retained_payload, payload_context, payload_context_identity,
102 persisted_call_fields, retained_payload_requires_policy,
103 },
104 transaction::{
105 TransactionPurpose, TransactionStatus, build_eip1559_transaction, compute_max_fee,
106 decode_signed_transaction, derive_fees, derive_gas_limit, sign_eip1559_transaction,
107 },
108 },
109 rpc::{
110 error::BroadcastError,
111 http::{BlockchainHttpRpcClient, EXECUTION_RPC_TIMEOUT_SECS},
112 log as rpc_log,
113 types::{RpcCallType, RpcTransaction, RpcTransactionReceipt},
114 verification::{
115 VerificationCoordinator, VerificationOutcome, Verified, VerifiedBlockHeader,
116 VerifiedCallTrace, VerifiedSimulation,
117 },
118 },
119};
120
121const RECEIPT_POLL_INTERVAL: Duration = Duration::from_secs(1);
122const MAX_PAYLOAD_OPERATION_BATCH_SIZE: usize = 1_000;
123const BPS_DENOMINATOR: u32 = 10_000;
124const ORDER_LIST_UNSUPPORTED: &str =
126 "Order lists are not supported; submit each order individually";
127const ORDER_MODIFY_UNSUPPORTED: &str = "Order modification is not supported";
129const ORDER_CANCEL_UNSUPPORTED: &str = "Order cancellation is not supported";
131
132const VENUE_EXECUTION_REPORTS_UNSUPPORTED: &str =
134 "Venue execution reports are not supported on the blockchain execution client";
135const MAX_REPLACEMENT_SCAN_BLOCKS: u64 = 4_096;
136
137#[derive(Debug)]
139pub struct BlockchainExecutionClient {
140 core: ExecutionClientCore,
141 emitter: ExecutionEventEmitter,
142 cache: BlockchainCache,
143 config: BlockchainExecutionClientConfig,
144 chain: SharedChain,
145 wallet_address: Address,
146 signer: Option<Arc<PrivateKeySigner>>,
147 payload_keys: Option<Arc<PayloadKeySet>>,
148 router_addresses: Vec<Address>,
149 transaction_limits: TransactionLimits,
150 weth_address: Address,
151 in_flight: Arc<Mutex<Option<InFlightSlot>>>,
152 wallet_balance: Arc<Mutex<WalletBalance>>,
153 erc20_contract: Erc20Contract,
154 http_rpc_client: Arc<BlockchainHttpRpcClient>,
155 verification: VerificationCoordinator,
156 pending_tasks: TaskGroup,
157}
158
159impl BlockchainExecutionClient {
160 pub fn new(
169 core_client: ExecutionClientCore,
170 config: BlockchainExecutionClientConfig,
171 ) -> anyhow::Result<Self> {
172 config.validate()?;
173
174 let transaction_limits = Self::transaction_limits(&config)?;
175 let chain = Arc::new(config.chain.clone());
176 let cache = BlockchainCache::new(Arc::clone(&chain));
177 let http_rpc_client = Arc::new(BlockchainHttpRpcClient::new(
178 config.http_rpc_url.clone().into_inner(),
179 config.rpc_requests_per_second,
180 None,
181 )?);
182
183 let verification_config = config.verification.as_ref().ok_or_else(|| {
184 anyhow::anyhow!("Independent Blockchain execution verification is required")
185 })?;
186 anyhow::ensure!(
187 verification_config.chain_anchor.chain_id == config.chain.chain_id,
188 "Verification chain anchor ID does not match the configured chain"
189 );
190 anyhow::ensure!(
191 verification_config.chain_anchor.chain_name == config.chain.name.to_string(),
192 "Verification chain anchor name does not match the configured chain"
193 );
194 let verification = VerificationCoordinator::new(
195 Arc::clone(&http_rpc_client),
196 config.http_rpc_url.expose_secret(),
197 verification_config,
198 config.rpc_requests_per_second,
199 )?;
200 let wallet_address = validate_address(config.wallet_address.as_str())?;
201 let erc20_contract = Erc20Contract::new_with_timeout(
202 Arc::clone(&http_rpc_client),
203 Some(EXECUTION_RPC_TIMEOUT_SECS),
204 true,
205 );
206
207 let router_addresses = config
208 .router_addresses
209 .iter()
210 .map(|address| validate_address(address.as_str()))
211 .collect::<anyhow::Result<Vec<_>>>()?;
212 if router_addresses.is_empty() {
213 anyhow::bail!("`router_addresses` must contain at least one router address");
214 }
215 let weth_address = validate_address(config.weth_address.as_str())?;
216 Self::validate_manifest_contracts(&config, &router_addresses, weth_address)?;
217
218 let mut token_universe = HashSet::new();
219
220 if let Some(specified_tokens) = &config.tokens {
221 for token in specified_tokens {
222 let token_address = validate_address(token.as_str())?;
223 token_universe.insert(token_address);
224 }
225 }
226 let wallet_balance = WalletBalance::new(token_universe);
227 let emitter = ExecutionEventEmitter::new(
228 get_atomic_clock_realtime(),
229 core_client.trader_id,
230 core_client.account_id,
231 core_client.account_type,
232 core_client.base_currency,
233 );
234
235 let pending_tasks = TaskGroup::new();
236
237 Ok(Self {
238 core: core_client,
239 emitter,
240 wallet_balance: Arc::new(Mutex::new(wallet_balance)),
241 chain,
242 cache,
243 config,
244 signer: None,
245 payload_keys: None,
246 router_addresses,
247 transaction_limits,
248 weth_address,
249 in_flight: Arc::new(Mutex::new(None)),
250 erc20_contract,
251 http_rpc_client,
252 verification,
253 wallet_address,
254 pending_tasks,
255 })
256 }
257
258 fn transaction_limits(
259 config: &BlockchainExecutionClientConfig,
260 ) -> anyhow::Result<TransactionLimits> {
261 let (
262 Some(allowed_token_pairs),
263 Some(slippage_bps),
264 Some(max_slippage_bps),
265 Some(max_order_amount),
266 Some(deadline_seconds),
267 Some(max_quote_age_blocks),
268 Some(receipt_timeout_secs),
269 ) = (
270 &config.allowed_token_pairs,
271 config.slippage_bps,
272 config.max_slippage_bps,
273 config.max_order_amount,
274 config.deadline_seconds,
275 config.max_quote_age_blocks,
276 config.receipt_timeout_secs,
277 )
278 else {
279 anyhow::bail!(
280 "Blockchain execution transaction limits are required: allowed_token_pairs, slippage_bps, max_slippage_bps, max_order_amount, deadline_seconds, max_quote_age_blocks, receipt_timeout_secs"
281 );
282 };
283
284 let mut parsed_pairs = HashSet::with_capacity(allowed_token_pairs.len());
285 for (token_in, token_out) in allowed_token_pairs {
286 parsed_pairs.insert((
287 validate_address(token_in.as_str())?,
288 validate_address(token_out.as_str())?,
289 ));
290 }
291
292 let quote_spend_limits = config.quote_spend_limits.as_deref().unwrap_or_default();
293 let mut parsed_quote_spend_limits = HashMap::with_capacity(quote_spend_limits.len());
294 for limit in quote_spend_limits {
295 let token_in = validate_address(limit.token_in.as_str())?;
296 let token_out = validate_address(limit.token_out.as_str())?;
297 let spend_token = validate_address(limit.spend_token.as_str())?;
298
299 if !parsed_pairs.contains(&(token_in, token_out)) {
300 anyhow::bail!(
301 "Quote spend limit pair {token_in} -> {token_out} is not in the `allowed_token_pairs` allowlist"
302 );
303 }
304
305 if spend_token != token_in {
306 anyhow::bail!(
307 "Quote spend limit for {token_in} -> {token_out} is denominated in {spend_token}; `spend_token` must match `token_in`"
308 );
309 }
310
311 if limit.max_amount.is_empty()
312 || !limit.max_amount.bytes().all(|byte| byte.is_ascii_digit())
313 {
314 anyhow::bail!(
315 "Quote spend limit `max_amount` '{}' must be a base-10 unsigned integer string",
316 limit.max_amount
317 );
318 }
319 let max_amount = U256::from_str(&limit.max_amount).map_err(|_| {
320 anyhow::anyhow!(
321 "Quote spend limit `max_amount` '{}' exceeds the U256 range",
322 limit.max_amount
323 )
324 })?;
325 let ceiling = QuoteSpendCeiling {
326 spend_token,
327 spend_token_decimals: limit.spend_token_decimals,
328 max_amount,
329 };
330
331 if parsed_quote_spend_limits
332 .insert((token_in, token_out), ceiling)
333 .is_some()
334 {
335 anyhow::bail!(
336 "Duplicate quote spend limit for token pair {token_in} -> {token_out}"
337 );
338 }
339 }
340
341 if slippage_bps > max_slippage_bps {
342 anyhow::bail!(
343 "`slippage_bps` {slippage_bps} exceeds `max_slippage_bps` {max_slippage_bps}"
344 );
345 }
346
347 if max_slippage_bps >= BPS_DENOMINATOR {
348 anyhow::bail!("`max_slippage_bps` {max_slippage_bps} must be below {BPS_DENOMINATOR}");
349 }
350
351 if !(1..=4_095).contains(&max_quote_age_blocks) {
352 anyhow::bail!("`max_quote_age_blocks` must be in 1..=4095");
353 }
354
355 Ok(TransactionLimits {
356 allowed_token_pairs: parsed_pairs,
357 quote_spend_limits: parsed_quote_spend_limits,
358 slippage_bps,
359 max_slippage_bps,
360 max_order_amount,
361 deadline_seconds,
362 max_quote_age_blocks,
363 receipt_timeout_secs,
364 })
365 }
366
367 fn validate_manifest_contracts(
368 config: &BlockchainExecutionClientConfig,
369 routers: &[Address],
370 weth: Address,
371 ) -> anyhow::Result<()> {
372 let verification = config.verification.as_ref().ok_or_else(|| {
373 anyhow::anyhow!("Independent Blockchain execution verification is required")
374 })?;
375 let manifest = &verification.deployment_manifest;
376 let role_addresses = |role| {
377 manifest
378 .contracts
379 .iter()
380 .filter(|contract| contract.role == role)
381 .map(|contract| {
382 Address::from_str(&contract.address)
383 .map_err(|_| anyhow::anyhow!("Deployment manifest address is invalid"))
384 })
385 .collect::<anyhow::Result<HashSet<_>>>()
386 };
387 let singleton = |role, description: &str| {
388 let addresses = role_addresses(role)?;
389 anyhow::ensure!(
390 addresses.len() == 1,
391 "Deployment manifest must contain exactly one {description} contract"
392 );
393 Ok(*addresses.iter().next().expect("singleton role address"))
394 };
395
396 let configured_routers = routers.iter().copied().collect::<HashSet<_>>();
397 anyhow::ensure!(
398 role_addresses(BlockchainContractRole::Router)? == configured_routers,
399 "Deployment manifest router set does not match `router_addresses`"
400 );
401 anyhow::ensure!(
402 singleton(BlockchainContractRole::WrappedNative, "wrapped native")? == weth,
403 "Deployment manifest wrapped native contract does not match `weth_address`"
404 );
405 let factory = singleton(BlockchainContractRole::Factory, "factory")?;
406 let registered_factory =
407 crate::exchanges::get_dex_extended(config.chain.name, &DexType::UniswapV3)
408 .map(|dex| dex.factory)
409 .ok_or_else(|| {
410 anyhow::anyhow!(
411 "No registered Uniswap V3 deployment for chain {}",
412 config.chain.name
413 )
414 })?;
415 anyhow::ensure!(
416 factory == registered_factory,
417 "Deployment manifest factory does not match the registered Uniswap V3 factory"
418 );
419 let quote_contract = singleton(BlockchainContractRole::Quote, "quote")?;
420
421 let mut token_decimals = HashMap::new();
422
423 for token in &manifest.tokens {
424 let address = Address::from_str(&token.address)
425 .map_err(|_| anyhow::anyhow!("Deployment manifest token address is invalid"))?;
426 anyhow::ensure!(
427 token_decimals.insert(address, token.decimals).is_none(),
428 "Deployment manifest contains a duplicate token identity"
429 );
430 }
431 anyhow::ensure!(
432 token_decimals.contains_key(&weth),
433 "Deployment manifest has no wrapped native token identity"
434 );
435
436 if let Some(tokens) = &config.tokens {
437 for token in tokens {
438 let address = validate_address(token)?;
439 anyhow::ensure!(
440 token_decimals.contains_key(&address),
441 "Configured token {address} has no deployment manifest identity"
442 );
443 }
444 }
445
446 for (token_in, token_out) in config.allowed_token_pairs.as_deref().unwrap_or_default() {
447 let token_in = validate_address(token_in)?;
448 let token_out = validate_address(token_out)?;
449 anyhow::ensure!(
450 token_in != token_out
451 && token_decimals.contains_key(&token_in)
452 && token_decimals.contains_key(&token_out),
453 "Allowed token pair {token_in} -> {token_out} is not fully pinned by the deployment manifest"
454 );
455 }
456
457 for limit in config.quote_spend_limits.as_deref().unwrap_or_default() {
458 let spend_token = validate_address(&limit.spend_token)?;
459 anyhow::ensure!(
460 token_decimals.get(&spend_token) == Some(&limit.spend_token_decimals),
461 "Quote spend limit decimals do not match the deployment manifest"
462 );
463 }
464
465 let pool_contracts = role_addresses(BlockchainContractRole::Pool)?;
466
467 for pool in &manifest.pools {
468 let pool_address = Address::from_str(&pool.address)
469 .map_err(|_| anyhow::anyhow!("Deployment manifest pool address is invalid"))?;
470 let pool_factory = Address::from_str(&pool.factory)
471 .map_err(|_| anyhow::anyhow!("Deployment manifest pool factory is invalid"))?;
472 let pool_quote = Address::from_str(&pool.quote_contract).map_err(|_| {
473 anyhow::anyhow!("Deployment manifest pool quote contract is invalid")
474 })?;
475 anyhow::ensure!(
476 pool_contracts.contains(&pool_address)
477 && pool_factory == factory
478 && pool_quote == quote_contract,
479 "Deployment manifest pool does not use the pinned pool, factory, and quote identities"
480 );
481 }
482 Ok(())
483 }
484
485 async fn fetch_native_currency_balance(&self) -> anyhow::Result<Money> {
486 let balance_u256 = self
487 .http_rpc_client
488 .get_balance_with_timeout(&self.wallet_address, None, Some(EXECUTION_RPC_TIMEOUT_SECS))
489 .await?;
490
491 let native_currency = self.chain.native_currency();
492
493 Money::from_u256(balance_u256, native_currency).map_err(Into::into)
494 }
495
496 async fn fetch_token_balance(
497 &mut self,
498 token_address: &Address,
499 ) -> anyhow::Result<TokenBalance> {
500 let token = if let Some(token) = self.cache.get_token(token_address) {
501 token.to_owned()
502 } else {
503 let token_info = self.erc20_contract.fetch_token_info(token_address).await?;
504 let token = Token::new(
505 Arc::clone(&self.chain),
506 *token_address,
507 token_info.name,
508 token_info.symbol,
509 token_info.decimals,
510 );
511 self.cache.add_token(token.clone()).await?;
512 token
513 };
514
515 let amount = self
516 .erc20_contract
517 .balance_of(token_address, &self.wallet_address)
518 .await?;
519 let token_balance = TokenBalance::new(amount, token);
520
521 Ok(token_balance)
525 }
526
527 async fn refresh_wallet_balances(&mut self) -> anyhow::Result<()> {
529 let (wallet_balance, balances) = self.fetch_wallet_balances().await?;
530 self.generate_account_state(
531 balances,
532 vec![],
533 true,
534 get_atomic_clock_realtime().get_time_ns(),
535 None,
536 )?;
537 *self.wallet_balance.lock() = wallet_balance;
538 Ok(())
539 }
540
541 async fn fetch_wallet_balances(
542 &mut self,
543 ) -> anyhow::Result<(WalletBalance, Vec<AccountBalance>)> {
544 let native_currency_balance = self.fetch_native_currency_balance().await?;
545 let token_universe = self.wallet_balance.lock().token_universe.clone();
546 let mut token_addresses = token_universe.iter().copied().collect::<Vec<_>>();
547 token_addresses.sort_unstable();
548
549 let mut token_balances = Vec::with_capacity(token_addresses.len());
550 for token_address in token_addresses {
551 let token_balance = self
552 .fetch_token_balance(&token_address)
553 .await
554 .with_context(|| format!("failed to fetch token balance for {token_address}"))?;
555 token_balances.push(token_balance);
556 }
557
558 let mut wallet_balance = WalletBalance::new(token_universe);
559 let balances = wallet_balance.replace_balances(native_currency_balance, token_balances)?;
560 log::debug!(
561 "Refreshed wallet balance with {} account balances",
562 balances.len()
563 );
564 Ok((wallet_balance, balances))
565 }
566
567 pub async fn preflight(
579 &self,
580 instrument_id: &InstrumentId,
581 ) -> anyhow::Result<BlockchainPreflightReport> {
582 let pool = self.resolve_pool(instrument_id)?;
583 let base_token = pool.get_base_token().clone();
584 let quote_token = pool.get_quote_token().clone();
585
586 let actual_chain_id = self.http_rpc_client.chain_id().await?;
587
588 let pool_code = self.http_rpc_client.get_code(&pool.address).await?;
589 let pool_check = PoolPreflightCheck {
590 instrument_id: pool.instrument_id,
591 address: pool.address,
592 has_deployed_code: !pool_code.is_empty(),
593 fee: pool.fee,
594 base_token: base_token.address,
595 quote_token: quote_token.address,
596 };
597
598 let mut routers = Vec::with_capacity(self.router_addresses.len());
599 for router in &self.router_addresses {
600 let code = self.http_rpc_client.get_code(router).await?;
601 routers.push(ContractCodeCheck {
602 address: *router,
603 has_deployed_code: !code.is_empty(),
604 });
605 }
606
607 let mut tokens = Vec::with_capacity(2);
608
609 for token in [&base_token, "e_token] {
610 let code = self.http_rpc_client.get_code(&token.address).await?;
611 let wallet_balance = self
612 .erc20_contract
613 .balance_of(&token.address, &self.wallet_address)
614 .await?;
615
616 let mut router_allowances = Vec::new();
617 if token.address == base_token.address {
618 router_allowances.reserve(self.router_addresses.len());
619 for router in &self.router_addresses {
620 let amount = self
621 .erc20_contract
622 .allowance(&token.address, &self.wallet_address, router)
623 .await?;
624 router_allowances.push((*router, amount));
625 }
626 }
627
628 tokens.push(TokenPreflightCheck {
629 address: token.address,
630 symbol: token.symbol.clone(),
631 has_deployed_code: !code.is_empty(),
632 wallet_balance,
633 router_allowances,
634 });
635 }
636
637 let native_balance_wei = self
638 .http_rpc_client
639 .get_balance_with_timeout(&self.wallet_address, None, Some(EXECUTION_RPC_TIMEOUT_SECS))
640 .await?;
641
642 let latest_block = self.http_rpc_client.latest_block().await?;
643 let base_fee_per_gas_wei = latest_block.base_fee_per_gas.ok_or_else(|| {
644 anyhow::anyhow!("Latest block {} has no base fee", latest_block.number)
645 })?;
646 let max_priority_fee_per_gas_wei = self.http_rpc_client.max_priority_fee_per_gas().await?;
647 let derived_max_fee_per_gas_wei = compute_max_fee(
648 base_fee_per_gas_wei,
649 max_priority_fee_per_gas_wei,
650 self.config.base_fee_buffer_bps,
651 )?;
652
653 Ok(BlockchainPreflightReport::new(
654 u64::from(self.chain.chain_id),
655 actual_chain_id,
656 pool_check,
657 routers,
658 tokens,
659 native_balance_wei,
660 base_fee_per_gas_wei,
661 max_priority_fee_per_gas_wei,
662 derived_max_fee_per_gas_wei,
663 u128::from(self.config.max_fee_per_gas_wei),
664 ))
665 }
666
667 #[allow(
680 clippy::large_futures,
681 reason = "keeps transaction verification futures inline without additional boxing"
682 )]
683 pub async fn wrap(&mut self, amount_wei: U256) -> anyhow::Result<B256> {
684 if amount_wei.is_zero() {
685 anyhow::bail!("Wrap amount must be positive");
686 }
687
688 self.ensure_transaction_ready(TransactionPurpose::Wrap)?;
689
690 let calldata = WETH9::depositCall {}.abi_encode();
691 let executor = self.transaction_executor()?;
692 let included = executor
693 .transact(
694 self.weth_address,
695 amount_wei,
696 Bytes::from(calldata),
697 TransactionPurpose::Wrap,
698 None,
699 TransactionAuthorization::Wrap {
700 weth: self.weth_address,
701 },
702 )
703 .await?;
704 let postconditions =
705 verify_wrap_balance_increase(&executor, &self.weth_address, amount_wei, &included)
706 .await?;
707 executor
708 .commit_verified_finality(&included, TransactionStatus::Finalized, &postconditions)
709 .await?;
710 executor
711 .database
712 .mark_execution_event_emitted(included.intent_id, "terminal")
713 .await?;
714 executor.release_slot();
715
716 Ok(included.tx_hash)
717 }
718
719 #[allow(
734 clippy::large_futures,
735 reason = "keeps transaction verification futures inline without additional boxing"
736 )]
737 pub async fn approve(
738 &mut self,
739 token: Address,
740 amount: U256,
741 router: Address,
742 ) -> anyhow::Result<B256> {
743 if !self.router_addresses.contains(&router) {
744 anyhow::bail!("Router {router} is not in the configured `router_addresses` allowlist");
745 }
746
747 if !amount.is_zero()
748 && !self
749 .transaction_limits
750 .allowed_token_pairs
751 .iter()
752 .any(|(token_in, _)| *token_in == token)
753 {
754 anyhow::bail!(
755 "Token {token} is not an input token in the configured `allowed_token_pairs`"
756 );
757 }
758
759 self.ensure_transaction_ready(TransactionPurpose::Approve)?;
760
761 let approval_amount = if amount.is_zero() {
762 U256::ZERO
763 } else if self.config.unlimited_approval {
764 U256::MAX
765 } else {
766 amount
767 };
768 let calldata = ERC20::approveCall {
769 spender: router,
770 amount: approval_amount,
771 }
772 .abi_encode();
773
774 let executor = self.transaction_executor()?;
775 let included = executor
776 .transact(
777 token,
778 U256::ZERO,
779 Bytes::from(calldata),
780 TransactionPurpose::Approve,
781 None,
782 TransactionAuthorization::Approve {
783 token,
784 router,
785 amount: approval_amount,
786 },
787 )
788 .await?;
789 let postconditions =
790 verify_approve_allowance(&executor, &token, &router, approval_amount, &included)
791 .await?;
792 executor
793 .commit_verified_finality(&included, TransactionStatus::Finalized, &postconditions)
794 .await?;
795 executor
796 .database
797 .mark_execution_event_emitted(included.intent_id, "terminal")
798 .await?;
799 executor.release_slot();
800
801 Ok(included.tx_hash)
802 }
803
804 fn uniswap_v3_factory(&self) -> anyhow::Result<Address> {
805 crate::exchanges::get_dex_extended(self.chain.name, &DexType::UniswapV3)
806 .map(|dex| dex.factory)
807 .ok_or_else(|| {
808 anyhow::anyhow!(
809 "No registered Uniswap V3 deployment for chain {}",
810 self.chain.name
811 )
812 })
813 }
814
815 fn resolve_pool(&self, instrument_id: &InstrumentId) -> anyhow::Result<Pool> {
816 let (blockchain, dex_type) = instrument_id.venue.parse_dex()?;
817 if blockchain != self.chain.name {
818 anyhow::bail!(
819 "Pool venue chain {blockchain} does not match the client chain {}",
820 self.chain.name
821 );
822 }
823
824 if dex_type != DexType::UniswapV3 {
825 anyhow::bail!("Unsupported DEX type {dex_type}; only UniswapV3 is supported");
826 }
827
828 let pool_identifier = PoolIdentifier::new_checked(instrument_id.symbol.as_str())?;
829 if !pool_identifier.is_address() {
830 anyhow::bail!(
831 "Pool identifier {pool_identifier} is a pool ID; only address identifiers are supported"
832 );
833 }
834
835 let pool = self
836 .core
837 .cache()
838 .pool(instrument_id)
839 .cloned()
840 .ok_or_else(|| {
841 anyhow::anyhow!(
842 "Unknown pool {instrument_id}; not found in the shared engine cache"
843 )
844 })?;
845
846 if pool.token0.get_token_priority() == pool.token1.get_token_priority() {
847 anyhow::bail!(
848 "Pool {instrument_id} tokens share a token priority; base and quote orientation is ambiguous"
849 );
850 }
851
852 Ok(pool)
853 }
854
855 pub async fn check_payload_storage(
866 &self,
867 batch_size: usize,
868 ) -> anyhow::Result<PayloadStorageCheck> {
869 let batch_size = validate_payload_operation_batch_size(batch_size)?;
870 let database = self.payload_operation_database().await?;
871 let keys = self.load_payload_keys()?;
872 database
873 .check_execution_payload_storage(keys.as_ref(), None, batch_size)
874 .await
875 .map(Into::into)
876 }
877
878 pub async fn protect_payload_storage(&self) -> anyhow::Result<()> {
888 let database = self.payload_operation_database().await?;
889 database.ensure_execution_transaction_schema().await?;
890 let keys = self
891 .load_payload_keys()?
892 .ok_or_else(|| anyhow::anyhow!("Payload protection requires an active payload key"))?;
893 database.ensure_execution_payload_storage(&keys).await
894 }
895
896 pub async fn rewrap_payload_storage(&self, batch_size: usize) -> anyhow::Result<()> {
906 let batch_size = validate_payload_operation_batch_size(batch_size)?;
907 let database = self.payload_operation_database().await?;
908 let keys = self
909 .load_payload_keys()?
910 .ok_or_else(|| anyhow::anyhow!("Payload rewrap requires an active payload key"))?;
911 database
912 .rewrap_execution_payload_storage(&keys, batch_size)
913 .await
914 }
915
916 pub async fn rollback_payload_storage(&self, batch_size: usize) -> anyhow::Result<()> {
926 let batch_size = validate_payload_operation_batch_size(batch_size)?;
927 let database = self.payload_operation_database().await?;
928 let keys = self
929 .load_payload_keys()?
930 .ok_or_else(|| anyhow::anyhow!("Payload rollback requires an active payload key"))?;
931 database
932 .rollback_execution_payload_storage(&keys, batch_size)
933 .await
934 }
935
936 async fn payload_operation_database(&self) -> anyhow::Result<BlockchainCacheDatabase> {
937 anyhow::ensure!(
938 !self.core.is_connected(),
939 "Disconnect the execution client before payload storage operations"
940 );
941
942 if let Some(database) = &self.cache.database {
943 return Ok(database.clone());
944 }
945 let options = self
946 .config
947 .postgres_cache_database_config
948 .as_ref()
949 .ok_or_else(|| anyhow::anyhow!("No Postgres cache database is configured"))?;
950 BlockchainCacheDatabase::connect(options.clone().into())
951 .await
952 .context("failed to connect to the execution database")
953 }
954
955 fn load_payload_keys(&self) -> anyhow::Result<Option<PayloadKeySet>> {
956 PayloadKeySet::load(
957 self.config.payload_key_env.as_deref(),
958 &self.config.payload_key_retired_env,
959 self.config.payload_deployment_id.as_deref(),
960 )
961 }
962
963 fn payload_policy(&self) -> PayloadPolicy {
964 PayloadPolicy {
965 chain_id: self.chain.chain_id,
966 signer: self.wallet_address,
967 gas_limit: self.config.gas_limit,
968 max_fee_per_gas: self.config.max_fee_per_gas_wei,
969 }
970 }
971
972 fn transaction_executor(&self) -> anyhow::Result<TransactionExecutor> {
973 let database = self.cache.database.clone().ok_or_else(|| {
974 anyhow::anyhow!("No durable store configured; refusing to submit a transaction")
975 })?;
976 let signer = self
977 .signer
978 .clone()
979 .ok_or_else(|| anyhow::anyhow!("Signer not initialized; connect the client first"))?;
980 let payload_keys = self.payload_keys.clone().ok_or_else(|| {
981 anyhow::anyhow!("Protected payload keys are not initialized; connect the client first")
982 })?;
983 let verification_config = self
984 .config
985 .verification
986 .as_ref()
987 .expect("verification config validated at construction");
988 let identities = std::iter::once(&verification_config.authoritative)
989 .chain(
990 verification_config
991 .verifiers
992 .iter()
993 .map(|provider| &provider.identity),
994 )
995 .collect::<Vec<_>>();
996
997 Ok(TransactionExecutor {
998 http_rpc_client: Arc::clone(&self.http_rpc_client),
999 verification: self.verification.clone(),
1000 manifest_version: verification_config.manifest_version.clone(),
1001 manifest_digest: verification_config.manifest_digest.clone(),
1002 deployment_manifest: Arc::new(verification_config.deployment_manifest.clone()),
1003 provider_ids: identities
1004 .iter()
1005 .map(|identity| identity.provider_id.clone())
1006 .collect(),
1007 operator_ids: identities
1008 .iter()
1009 .map(|identity| identity.operator_id.clone())
1010 .collect(),
1011 failure_domain_ids: identities
1012 .iter()
1013 .flat_map(|identity| identity.failure_domain_ids.iter().cloned())
1014 .collect(),
1015 database,
1016 signer,
1017 payload_keys,
1018 in_flight: Arc::clone(&self.in_flight),
1019 wallet_balance: Arc::clone(&self.wallet_balance),
1020 account_id: self.core.account_id,
1021 wallet_address: self.wallet_address,
1022 chain_id: self.chain.chain_id,
1023 max_fee_per_gas_wei: self.config.max_fee_per_gas_wei,
1024 base_fee_buffer_bps: self.config.base_fee_buffer_bps,
1025 gas_limit: self.config.gas_limit,
1026 gas_buffer_bps: self.config.gas_buffer_bps,
1027 receipt_timeout: receipt_timeout(self.transaction_limits.receipt_timeout_secs),
1028 receipt_max_polls: receipt_max_polls(self.transaction_limits.receipt_timeout_secs),
1029 })
1030 }
1031
1032 fn restore_swap_plan(&self, intent: &ExecutionIntentRow) -> anyhow::Result<SwapPlan> {
1033 let client_order_id = ClientOrderId::new_checked(
1034 intent
1035 .client_order_id
1036 .as_deref()
1037 .ok_or_else(|| anyhow::anyhow!("Persisted swap intent has no client order ID"))?,
1038 )?;
1039 let order = self
1040 .core
1041 .cache()
1042 .try_order_owned(&client_order_id)
1043 .with_context(|| {
1044 format!(
1045 "Cannot reconcile swap intent {} because order {client_order_id} is not restored",
1046 intent.id
1047 )
1048 })?;
1049 let instrument_id = InstrumentId::from_str(
1050 intent
1051 .instrument_id
1052 .as_deref()
1053 .ok_or_else(|| anyhow::anyhow!("Persisted swap intent has no instrument ID"))?,
1054 )?;
1055 anyhow::ensure!(
1056 order.instrument_id() == instrument_id,
1057 "Persisted swap instrument {instrument_id} does not match restored order instrument {}",
1058 order.instrument_id()
1059 );
1060 anyhow::ensure!(
1061 intent.trader_id.as_deref() == Some(order.trader_id().as_str()),
1062 "Persisted swap trader does not match restored order"
1063 );
1064 anyhow::ensure!(
1065 intent.strategy_id.as_deref() == Some(order.strategy_id().as_str()),
1066 "Persisted swap strategy does not match restored order"
1067 );
1068 anyhow::ensure!(
1069 intent.account_id.as_deref() == Some(self.core.account_id.as_str()),
1070 "Persisted swap account does not match execution client account"
1071 );
1072
1073 let pool = self.resolve_pool(&instrument_id)?;
1074 let pool_address = Address::from_str(
1075 intent
1076 .pool_address
1077 .as_deref()
1078 .ok_or_else(|| anyhow::anyhow!("Persisted swap intent has no pool address"))?,
1079 )?;
1080 anyhow::ensure!(
1081 pool.address == pool_address,
1082 "Persisted pool {pool_address} does not match restored pool {}",
1083 pool.address
1084 );
1085 let amount_in = U256::from_str(
1086 intent
1087 .amount_in
1088 .as_deref()
1089 .ok_or_else(|| anyhow::anyhow!("Persisted swap intent has no input amount"))?,
1090 )?;
1091 let fee = U24::try_from(
1092 pool.fee
1093 .ok_or_else(|| anyhow::anyhow!("Restored pool {instrument_id} has no fee"))?,
1094 )?;
1095 let quote_token = pool.get_quote_token();
1096 let quote_currency = Currency::new_checked(
1097 "e_token.symbol,
1098 quote_token.decimals,
1099 0,
1100 "e_token.name,
1101 CurrencyType::Crypto,
1102 )?;
1103 let (token_in, token_out) = swap_token_pair(
1104 order.order_side(),
1105 pool.get_base_token().address,
1106 quote_token.address,
1107 );
1108 let factory = self.uniswap_v3_factory()?;
1109 anyhow::ensure!(
1110 pool.dex.factory == factory,
1111 "Restored pool {instrument_id} references factory {}, expected registered factory {factory}",
1112 pool.dex.factory
1113 );
1114
1115 Ok(SwapPlan {
1116 order,
1117 quote_currency,
1118 pool,
1119 instrument_id,
1120 pool_address,
1121 router: Address::from_str(&intent.transaction_to)?,
1122 factory,
1123 weth: self.weth_address,
1124 token_in,
1125 token_out,
1126 fee,
1127 amount_in,
1128 min_amount_out: U256::ZERO,
1129 slippage_bps: 0,
1130 quote_spend_ceiling: None,
1131 profiler_position: None,
1132 })
1133 }
1134
1135 #[allow(
1136 clippy::large_futures,
1137 reason = "keeps transaction verification futures inline without additional boxing"
1138 )]
1139 async fn reconcile_unresolved_execution(&self) -> anyhow::Result<()> {
1140 let database = self.cache.database.clone().ok_or_else(|| {
1141 anyhow::anyhow!("No durable store configured for execution reconciliation")
1142 })?;
1143 let _payload_lease = database
1144 .acquire_execution_payload_lease(self.payload_keys.as_deref().ok_or_else(|| {
1145 anyhow::anyhow!("Protected payload keys are required for execution recovery")
1146 })?)
1147 .await?;
1148 let wallet_address = self.wallet_address.to_string();
1149 anyhow::ensure!(
1150 !database
1151 .has_recoverable_signed_execution(self.chain.chain_id, &wallet_address)
1152 .await?,
1153 "A recoverable execution for wallet {} retains signed transaction bytes; refusing to reuse its nonce without explicit recovery",
1154 self.wallet_address
1155 );
1156 let Some(intent) = database
1157 .get_active_execution_intent(self.chain.chain_id, &wallet_address)
1158 .await?
1159 else {
1160 return Ok(());
1161 };
1162 anyhow::ensure!(
1163 intent.schema_version == crate::execution::transaction::EXECUTION_SCHEMA_VERSION,
1164 "Execution intent {} uses unsupported schema version {}",
1165 intent.id,
1166 intent.schema_version
1167 );
1168
1169 if intent.status == "prepared" {
1170 database
1171 .mark_execution_intent_recoverable(intent.id)
1172 .await?;
1173 release_preparing_slot(&self.in_flight);
1174 return Ok(());
1175 }
1176
1177 let purpose = TransactionPurpose::parse(&intent.purpose).ok_or_else(|| {
1178 anyhow::anyhow!(
1179 "Execution intent {} has unknown purpose {}",
1180 intent.id,
1181 intent.purpose
1182 )
1183 })?;
1184 let nonce = intent
1185 .nonce
1186 .ok_or_else(|| anyhow::anyhow!("Active execution intent {} has no nonce", intent.id))?;
1187 *self.in_flight.lock() = Some(InFlightSlot::Recovering(RecoveryTransaction {
1188 intent_id: intent.id,
1189 nonce,
1190 purpose,
1191 }));
1192 let hashes = database.get_execution_transaction_hashes(intent.id).await?;
1193 let current = current_execution_hash(intent.id, &hashes)?;
1194 let tx_hash = B256::from_str(¤t.transaction_hash).with_context(|| {
1195 format!(
1196 "Execution intent {} has invalid transaction hash {}",
1197 intent.id, current.transaction_hash
1198 )
1199 })?;
1200 let policy = PayloadPolicy {
1201 chain_id: self.chain.chain_id,
1202 signer: self.wallet_address,
1203 gas_limit: self.config.gas_limit,
1204 max_fee_per_gas: self.config.max_fee_per_gas_wei,
1205 };
1206 let mut authenticated_payloads = HashMap::new();
1207 let mut current_payload = None;
1208
1209 for hash in &hashes {
1210 anyhow::ensure!(
1211 hash.intent_id == intent.id,
1212 "Persisted transaction row references intent {}, expected {}",
1213 hash.intent_id,
1214 intent.id
1215 );
1216 anyhow::ensure!(
1217 hash.chain_id == self.chain.chain_id,
1218 "Persisted transaction row chain ID {} does not match configured chain ID {}",
1219 hash.chain_id,
1220 self.chain.chain_id
1221 );
1222
1223 if !hash.payload_expected {
1224 anyhow::ensure!(
1225 hash.raw_transaction.is_none() && hash.sealed_transaction.is_none(),
1226 "Replacement transaction {} unexpectedly retains signed bytes",
1227 hash.transaction_hash
1228 );
1229 continue;
1230 }
1231 let raw_transaction = open_execution_payload(
1232 self.payload_keys
1233 .as_deref()
1234 .expect("payload keys checked above"),
1235 policy,
1236 &intent,
1237 hash,
1238 "recovery",
1239 )
1240 .map_err(|e| {
1241 anyhow::anyhow!(
1242 "Execution intent {} signed transaction {} failed authentication: {e}",
1243 intent.id,
1244 hash.transaction_hash
1245 )
1246 })?;
1247 let authenticated_hash = B256::from_str(&hash.transaction_hash).with_context(|| {
1248 format!(
1249 "Execution intent {} has invalid transaction hash {}",
1250 intent.id, hash.transaction_hash
1251 )
1252 })?;
1253 anyhow::ensure!(
1254 authenticated_payloads
1255 .insert(authenticated_hash, raw_transaction.clone())
1256 .is_none(),
1257 "Execution intent {} has duplicate authenticated transaction hash {}",
1258 intent.id,
1259 hash.transaction_hash
1260 );
1261
1262 if hash.id == current.id {
1263 current_payload = Some(raw_transaction);
1264 }
1265 }
1266 anyhow::ensure!(
1267 !authenticated_payloads.is_empty(),
1268 "Execution intent {} has no persisted signed transaction bytes",
1269 intent.id
1270 );
1271
1272 if intent.status == "broadcast" {
1273 anyhow::ensure!(
1274 current_payload.is_some(),
1275 "Broadcast execution intent {} has no persisted signed transaction bytes",
1276 intent.id
1277 );
1278 }
1279
1280 if intent.status == "signed" {
1281 anyhow::bail!(
1282 "Execution intent {} has a signed transaction {} that was not authorized for broadcast; its nonce remains reserved pending explicit recovery",
1283 intent.id,
1284 tx_hash
1285 );
1286 }
1287
1288 *self.in_flight.lock() = Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
1289 intent_id: intent.id,
1290 nonce,
1291 tx_hash,
1292 purpose,
1293 }));
1294
1295 let plan = if purpose == TransactionPurpose::Swap {
1296 Some(self.restore_swap_plan(&intent)?)
1297 } else {
1298 None
1299 };
1300
1301 if let Some(plan) = &plan
1302 && !intent.acknowledgement_emitted
1303 {
1304 if plan.order.ts_submitted().is_none() {
1305 self.emitter.emit_order_submitted(&plan.order);
1306 }
1307 database
1308 .mark_execution_event_emitted(intent.id, "acknowledgement")
1309 .await?;
1310 }
1311
1312 let executor = self.transaction_executor()?;
1313 let mut prepared = PreparedTransaction {
1314 intent_id: intent.id,
1315 created_block: intent.created_block,
1316 nonce,
1317 tx_hash,
1318 raw_tx: current_payload.unwrap_or_default(),
1319 payload_lease: None,
1320 };
1321
1322 let finality_already_committed = matches!(intent.status.as_str(), "finalized" | "reverted");
1323 if !finality_already_committed {
1324 match executor
1325 .authorize_rebroadcast(&prepared, &intent, purpose)
1326 .await?
1327 {
1328 ReconciliationAuthorization::Rebroadcast => {
1329 match executor.broadcast(&prepared).await? {
1330 BroadcastOutcome::Accepted => {}
1331 BroadcastOutcome::Ambiguous(message) => log::warn!("{message}"),
1332 }
1333 }
1334 ReconciliationAuthorization::Retain => {
1335 log::warn!(
1336 "Rebroadcast of transaction {} was suppressed by verified reconciliation state",
1337 prepared.tx_hash
1338 );
1339 }
1340 ReconciliationAuthorization::ScanReplacement(head) => {
1341 let Some((replacement_hash, replacement_payload)) = executor
1342 .scan_canonical_replacement(&intent, nonce, head, &authenticated_payloads)
1343 .await?
1344 else {
1345 log::warn!(
1346 "Canonical replacement scan for intent {} reached its bounded verified window",
1347 intent.id
1348 );
1349 return Ok(());
1350 };
1351 prepared.tx_hash = replacement_hash;
1352 prepared.raw_tx = replacement_payload;
1353 *self.in_flight.lock() =
1354 Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
1355 intent_id: intent.id,
1356 nonce,
1357 tx_hash: replacement_hash,
1358 purpose,
1359 }));
1360 }
1361 }
1362 }
1363 let outcome = if finality_already_committed {
1364 let receipt = verified_value(
1365 executor.verification.verify_receipt(&tx_hash).await,
1366 "persisted terminal receipt",
1367 )?;
1368 let finality = executor
1369 .receipt_is_stably_finalized(&receipt)
1370 .await?
1371 .ok_or_else(|| {
1372 anyhow::anyhow!(
1373 "Persisted terminal transaction {tx_hash} is not stable at the finalized boundary"
1374 )
1375 })?;
1376 let included = IncludedTransaction {
1377 intent_id: intent.id,
1378 nonce,
1379 tx_hash,
1380 block_number: receipt.block_number,
1381 receipt,
1382 finality,
1383 };
1384
1385 if intent.status == "finalized" {
1386 InclusionOutcome::Finalized(included)
1387 } else {
1388 InclusionOutcome::Reverted(included)
1389 }
1390 } else {
1391 executor.await_finality(&prepared).await?
1392 };
1393
1394 match outcome {
1395 InclusionOutcome::Finalized(mut included) => {
1396 let trace_purpose = match (&plan, purpose) {
1397 (Some(plan), TransactionPurpose::Swap) => match plan.order.order_side() {
1398 OrderSide::Sell => "swap_sell",
1399 OrderSide::Buy => "swap_buy",
1400 },
1401 (None, TransactionPurpose::Wrap) => "wrap",
1402 (None, TransactionPurpose::Approve) => "approve",
1403 _ => anyhow::bail!("Restored transaction purpose is inconsistent"),
1404 };
1405 included.finality.decisions.extend(
1406 verify_finalized_transaction(
1407 &included,
1408 &intent,
1409 nonce,
1410 &prepared.raw_tx,
1411 &executor,
1412 trace_purpose,
1413 )
1414 .await?,
1415 );
1416
1417 if let Some(plan) = plan {
1418 let fill = validate_finalized_swap_fill(&plan, &included)?;
1419 let wallet =
1420 load_verified_wallet_after_fill(&plan, &included, &executor).await?;
1421 if !finality_already_committed {
1422 executor
1423 .commit_verified_finality(
1424 &included,
1425 TransactionStatus::Finalized,
1426 &wallet.decisions,
1427 )
1428 .await?;
1429 }
1430 complete_finalized_swap(
1431 &plan,
1432 intent.id,
1433 included.tx_hash,
1434 fill,
1435 wallet,
1436 &executor,
1437 &self.emitter,
1438 )
1439 .await?;
1440 executor.release_slot();
1441 } else {
1442 let postconditions = self
1443 .verify_recovered_operator_transaction(
1444 &intent, purpose, &included, &executor,
1445 )
1446 .await?;
1447
1448 if !finality_already_committed {
1449 executor
1450 .commit_verified_finality(
1451 &included,
1452 TransactionStatus::Finalized,
1453 &postconditions,
1454 )
1455 .await?;
1456 }
1457 database
1458 .mark_execution_event_emitted(intent.id, "terminal")
1459 .await?;
1460 executor.release_slot();
1461 }
1462 }
1463 InclusionOutcome::Reverted(mut included) => {
1464 let trace_purpose = match (&plan, purpose) {
1465 (Some(plan), TransactionPurpose::Swap) => match plan.order.order_side() {
1466 OrderSide::Sell => "swap_sell",
1467 OrderSide::Buy => "swap_buy",
1468 },
1469 (None, TransactionPurpose::Wrap) => "wrap",
1470 (None, TransactionPurpose::Approve) => "approve",
1471 _ => anyhow::bail!("Restored transaction purpose is inconsistent"),
1472 };
1473 included.finality.decisions.extend(
1474 verify_finalized_transaction(
1475 &included,
1476 &intent,
1477 nonce,
1478 &prepared.raw_tx,
1479 &executor,
1480 trace_purpose,
1481 )
1482 .await?,
1483 );
1484
1485 if !finality_already_committed {
1486 executor
1487 .commit_verified_finality(&included, TransactionStatus::Reverted, &[])
1488 .await?;
1489 }
1490
1491 if let Some(plan) = plan
1492 && plan.order.status() != OrderStatus::Rejected
1493 {
1494 send_reverted_order(&self.emitter, &plan.order, &included)?;
1495 }
1496 database
1497 .mark_execution_event_emitted(intent.id, "terminal")
1498 .await?;
1499 executor.release_slot();
1500 }
1501 InclusionOutcome::Pending(message) => log::warn!("{message}"),
1502 }
1503 Ok(())
1504 }
1505
1506 async fn verify_recovered_operator_transaction(
1512 &self,
1513 intent: &ExecutionIntentRow,
1514 purpose: TransactionPurpose,
1515 included: &IncludedTransaction,
1516 executor: &TransactionExecutor,
1517 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
1518 let (to, input, value) = persisted_call_fields(intent)?;
1519
1520 match purpose {
1521 TransactionPurpose::Wrap => {
1522 verify_wrap_balance_increase(executor, &to, value, included).await
1523 }
1524 TransactionPurpose::Approve => {
1525 let call = ERC20::approveCall::abi_decode(&input)
1526 .with_context(|| "persisted approve calldata is invalid")?;
1527 verify_approve_allowance(executor, &to, &call.spender, call.amount, included).await
1528 }
1529 TransactionPurpose::Swap => {
1530 unreachable!("swap intents restore a swap plan")
1531 }
1532 }
1533 }
1534
1535 fn ensure_transaction_ready(&self, purpose: TransactionPurpose) -> anyhow::Result<()> {
1536 if !self.core.is_connected() {
1537 anyhow::bail!("Blockchain execution client is not connected");
1538 }
1539
1540 {
1541 let slot = self.in_flight.lock();
1542 if let Some(in_flight) = *slot {
1543 return Err(in_flight_limit_error(&in_flight));
1544 }
1545 }
1546
1547 if !self.cache.has_database() {
1548 anyhow::bail!(
1549 "No durable store configured; refusing to submit a {} transaction",
1550 purpose.as_str()
1551 );
1552 }
1553 Ok(())
1554 }
1555
1556 fn prepare_swap(&self, cmd: &SubmitOrder, order: &OrderAny) -> anyhow::Result<SwapPlan> {
1563 let instrument_id = order.instrument_id();
1564 let pool = self.resolve_pool(&instrument_id)?;
1565
1566 if order.order_type() != OrderType::Market {
1567 anyhow::bail!(
1568 "Unsupported order type {}; only Market is supported",
1569 order.order_type()
1570 );
1571 }
1572
1573 if !matches!(order.order_side(), OrderSide::Buy | OrderSide::Sell) {
1574 anyhow::bail!(
1575 "Unsupported order side {}; only Buy and Sell are supported",
1576 order.order_side()
1577 );
1578 }
1579
1580 if order.is_quote_quantity() {
1581 anyhow::bail!(OrderDeniedReason::unsupported_quote_quantity());
1582 }
1583
1584 let fee = pool
1585 .fee
1586 .ok_or_else(|| anyhow::anyhow!("Pool {instrument_id} has no fee tier"))?;
1587 let fee = U24::try_from(fee)
1588 .map_err(|_| anyhow::anyhow!("Pool {instrument_id} fee {fee} exceeds uint24"))?;
1589
1590 let base_token = pool.get_base_token();
1591 let quote_token = pool.get_quote_token();
1592 let quote_currency = Currency::new_checked(
1593 "e_token.symbol,
1594 quote_token.decimals,
1595 0,
1596 "e_token.name,
1597 CurrencyType::Crypto,
1598 )?;
1599 let (token_in, token_out) =
1600 swap_token_pair(order.order_side(), base_token.address, quote_token.address);
1601
1602 if !self
1603 .transaction_limits
1604 .allowed_token_pairs
1605 .contains(&(token_in, token_out))
1606 {
1607 anyhow::bail!(
1608 "Token pair {token_in} -> {token_out} is not in the `allowed_token_pairs` allowlist"
1609 );
1610 }
1611
1612 let base_amount = quantity_to_raw_amount(order.quantity(), base_token.decimals)?;
1613 if base_amount > U256::from(self.transaction_limits.max_order_amount) {
1614 anyhow::bail!(
1615 "Order amount {base_amount} exceeds the configured `max_order_amount` {}",
1616 self.transaction_limits.max_order_amount
1617 );
1618 }
1619
1620 let slippage_bps = match cmd
1621 .params
1622 .as_ref()
1623 .and_then(|params| params.get_u64("slippage_bps"))
1624 {
1625 Some(value) => u32::try_from(value).map_err(|_| {
1626 anyhow::anyhow!("slippage_bps parameter {value} exceeds the u32 range")
1627 })?,
1628 None => self.transaction_limits.slippage_bps,
1629 };
1630
1631 if slippage_bps > self.transaction_limits.max_slippage_bps {
1632 anyhow::bail!(
1633 "Slippage {slippage_bps} bps exceeds the configured `max_slippage_bps` {}",
1634 self.transaction_limits.max_slippage_bps
1635 );
1636 }
1637
1638 let quote_spend_ceiling = if order.order_side() == OrderSide::Buy {
1639 let ceiling = self
1640 .transaction_limits
1641 .quote_spend_limits
1642 .get(&(token_in, token_out))
1643 .ok_or_else(|| {
1644 anyhow::anyhow!(
1645 "No `quote_spend_limits` entry for BUY token pair {token_in} -> {token_out}"
1646 )
1647 })?;
1648 anyhow::ensure!(
1649 ceiling.spend_token == quote_token.address,
1650 "Quote spend limit for {token_in} -> {token_out} is denominated in {}, expected quote token {}",
1651 ceiling.spend_token,
1652 quote_token.address
1653 );
1654 anyhow::ensure!(
1655 ceiling.spend_token_decimals == quote_token.decimals,
1656 "Quote spend limit for token {} uses {} decimals, expected pool quote-token decimals {}",
1657 ceiling.spend_token,
1658 ceiling.spend_token_decimals,
1659 quote_token.decimals
1660 );
1661 Some(ceiling)
1662 } else {
1663 None
1664 };
1665
1666 let profiler = self
1667 .core
1668 .cache()
1669 .pool_profiler(&instrument_id)
1670 .cloned()
1671 .ok_or_else(|| {
1672 anyhow::anyhow!(
1673 "No pool profiler for {instrument_id}; an active data subscription is required to quote the swap"
1674 )
1675 })?;
1676
1677 if !profiler.is_initialized {
1678 anyhow::bail!("Pool profiler for {instrument_id} is not initialized");
1679 }
1680 let profiler_position = profiler.last_processed_event.clone().ok_or_else(|| {
1681 anyhow::anyhow!("Pool profiler for {instrument_id} has processed no events")
1682 })?;
1683
1684 let zero_for_one = token_in == pool.token0.address;
1685 let (amount_in, quoted_amount_out) = match order.order_side() {
1686 OrderSide::Sell => {
1687 let quote = profiler
1688 .swap_exact_in(base_amount, zero_for_one, None)
1689 .map_err(|e| anyhow::anyhow!("Swap quote failed for {instrument_id}: {e}"))?;
1690 let amount_filled = if zero_for_one {
1691 quote.amount0
1692 } else {
1693 quote.amount1
1694 };
1695
1696 if amount_filled != I256::from(base_amount) {
1697 anyhow::bail!(
1698 "Local quote for {instrument_id} filled {amount_filled} of the {base_amount} order amount; pool liquidity cannot fill the order"
1699 );
1700 }
1701 (base_amount, exact_output_amount("e, zero_for_one)?)
1702 }
1703 OrderSide::Buy => {
1704 let quote = profiler
1705 .swap_exact_out(base_amount, zero_for_one, None)
1706 .map_err(|e| anyhow::anyhow!("Swap quote failed for {instrument_id}: {e}"))?;
1707 let amount_in = quote.get_input_amount();
1708 if amount_in.is_zero() {
1709 anyhow::bail!("Local quote for {instrument_id} produced a zero quote input");
1710 }
1711 let ceiling = quote_spend_ceiling.ok_or_else(|| {
1712 anyhow::anyhow!(
1713 "No `quote_spend_limits` entry for BUY token pair {token_in} -> {token_out}"
1714 )
1715 })?;
1716
1717 if amount_in > ceiling.max_amount {
1718 anyhow::bail!(
1719 "BUY quote amount {amount_in} exceeds the configured `quote_spend_limits` maximum {} for {token_in} -> {token_out}",
1720 ceiling.max_amount
1721 );
1722 }
1723 (amount_in, base_amount)
1724 }
1725 };
1726 let min_amount_out = derive_min_amount_out(quoted_amount_out, slippage_bps)?;
1727
1728 self.ensure_transaction_ready(TransactionPurpose::Swap)?;
1729
1730 if self.signer.is_none() {
1731 anyhow::bail!("Signer not initialized; connect the client first");
1732 }
1733
1734 let pool_address = pool.address;
1735 let factory = self.uniswap_v3_factory()?;
1736 anyhow::ensure!(
1737 pool.dex.factory == factory,
1738 "Pool {instrument_id} references factory {}, expected registered factory {factory}",
1739 pool.dex.factory
1740 );
1741
1742 Ok(SwapPlan {
1743 order: order.clone(),
1744 pool,
1745 quote_currency,
1746 instrument_id,
1747 pool_address,
1748 router: self.router_addresses[0],
1749 factory,
1750 weth: self.weth_address,
1751 token_in,
1752 token_out,
1753 fee,
1754 amount_in,
1755 min_amount_out,
1756 slippage_bps,
1757 quote_spend_ceiling: quote_spend_ceiling.copied(),
1758 profiler_position: Some(profiler_position),
1759 })
1760 }
1761}
1762
1763#[derive(Debug, Clone, PartialEq, Eq)]
1765pub struct PayloadStorageCheck {
1766 pub protected: bool,
1768 pub deployment_id: Option<String>,
1770 pub plaintext_rows: u64,
1772 pub original_rows: u64,
1774 pub replacement_rows: u64,
1776 pub authenticated_rows: u64,
1778 pub key_ids: Vec<String>,
1780 pub read_roles: Vec<String>,
1782}
1783
1784impl From<ExecutionPayloadCheck> for PayloadStorageCheck {
1785 fn from(value: ExecutionPayloadCheck) -> Self {
1786 Self {
1787 protected: value.protected,
1788 deployment_id: value.deployment_id,
1789 plaintext_rows: value.plaintext_rows,
1790 original_rows: value.original_rows,
1791 replacement_rows: value.replacement_rows,
1792 authenticated_rows: value.authenticated_rows,
1793 key_ids: value.key_ids,
1794 read_roles: value.read_roles,
1795 }
1796 }
1797}
1798
1799#[derive(Debug, Clone, Copy)]
1801struct InFlightTransaction {
1802 intent_id: i64,
1803 nonce: u64,
1804 tx_hash: B256,
1805 purpose: TransactionPurpose,
1806}
1807
1808#[derive(Debug, Clone, Copy)]
1809struct RecoveryTransaction {
1810 intent_id: i64,
1811 nonce: u64,
1812 purpose: TransactionPurpose,
1813}
1814
1815#[derive(Debug, Clone, Copy)]
1823enum InFlightSlot {
1824 Preparing(TransactionPurpose),
1826 Recovering(RecoveryTransaction),
1828 AwaitingFinality(InFlightTransaction),
1830}
1831
1832#[derive(Debug, Clone)]
1833struct IncludedTransaction {
1834 intent_id: i64,
1835 nonce: u64,
1836 tx_hash: B256,
1837 block_number: u64,
1838 receipt: RpcTransactionReceipt,
1839 finality: StableFinality,
1840}
1841
1842#[derive(Debug, Clone)]
1843struct StableFinality {
1844 decisions: Vec<ExecutionVerificationDecision>,
1845 inclusion_header: ExecutionVerifiedHeader,
1846 finalized_headers: Vec<ExecutionVerifiedHeader>,
1847}
1848
1849#[derive(Debug, Clone, Copy)]
1850enum TransactionAuthorization {
1851 Wrap {
1852 weth: Address,
1853 },
1854 Approve {
1855 token: Address,
1856 router: Address,
1857 amount: U256,
1858 },
1859}
1860
1861fn in_flight_limit_error(slot: &InFlightSlot) -> anyhow::Error {
1863 match slot {
1864 InFlightSlot::Preparing(purpose) => anyhow::anyhow!(
1865 "A {} transaction is being prepared; at most one transaction can be in flight",
1866 purpose.as_str()
1867 ),
1868 InFlightSlot::Recovering(recovery) => anyhow::anyhow!(
1869 "Execution intent {} ({}, nonce {}) retains signer ownership pending recovery; at most one transaction can be in flight",
1870 recovery.intent_id,
1871 recovery.purpose.as_str(),
1872 recovery.nonce
1873 ),
1874 InFlightSlot::AwaitingFinality(in_flight) => anyhow::anyhow!(
1875 "Transaction {} (intent {}, {}, nonce {}) is still awaiting finality; at most one transaction can be in flight",
1876 in_flight.tx_hash,
1877 in_flight.intent_id,
1878 in_flight.purpose.as_str(),
1879 in_flight.nonce
1880 ),
1881 }
1882}
1883
1884fn release_preparing_slot(in_flight: &Mutex<Option<InFlightSlot>>) {
1889 let mut slot = in_flight.lock();
1890 if matches!(*slot, Some(InFlightSlot::Preparing(_))) {
1891 *slot = None;
1892 }
1893}
1894
1895fn release_preparing_if_reservation_not_committed(
1896 in_flight: &Mutex<Option<InFlightSlot>>,
1897 error: &anyhow::Error,
1898) {
1899 if reservation_failure_proven_not_committed(error) {
1900 release_preparing_slot(in_flight);
1901 }
1902}
1903
1904#[derive(Debug)]
1905struct TransactionLimits {
1906 allowed_token_pairs: HashSet<(Address, Address)>,
1907 quote_spend_limits: HashMap<(Address, Address), QuoteSpendCeiling>,
1908 slippage_bps: u32,
1909 max_slippage_bps: u32,
1910 max_order_amount: u64,
1911 deadline_seconds: u64,
1912 max_quote_age_blocks: u64,
1913 receipt_timeout_secs: u64,
1914}
1915
1916#[derive(Debug, Clone, Copy)]
1917struct QuoteSpendCeiling {
1918 spend_token: Address,
1919 spend_token_decimals: u8,
1920 max_amount: U256,
1921}
1922
1923struct PreparedTransaction {
1925 intent_id: i64,
1926 created_block: u64,
1927 nonce: u64,
1928 tx_hash: B256,
1929 raw_tx: Vec<u8>,
1930 payload_lease: Option<ExecutionPayloadLease>,
1931}
1932
1933impl Debug for PreparedTransaction {
1934 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1935 f.debug_struct(stringify!(PreparedTransaction))
1936 .field("intent_id", &self.intent_id)
1937 .field("created_block", &self.created_block)
1938 .field("nonce", &self.nonce)
1939 .field("tx_hash", &self.tx_hash)
1940 .field("raw_tx", &"<redacted>")
1941 .field(
1942 "payload_lease",
1943 &self.payload_lease.as_ref().map(|_| "held"),
1944 )
1945 .finish()
1946 }
1947}
1948
1949#[derive(Debug)]
1951enum BroadcastOutcome {
1952 Accepted,
1954 Ambiguous(String),
1956}
1957
1958#[derive(Debug)]
1960enum InclusionOutcome {
1961 Finalized(IncludedTransaction),
1963 Reverted(IncludedTransaction),
1965 Pending(String),
1968}
1969
1970enum ReconciliationAuthorization {
1971 Rebroadcast,
1972 Retain,
1973 ScanReplacement(VerifiedBlockHeader),
1974}
1975
1976fn verified_value<T>(outcome: VerificationOutcome<T>, context: &str) -> anyhow::Result<T> {
1977 required_verification(outcome, context).map(|verified| verified.value)
1978}
1979
1980fn required_verification<T>(
1981 outcome: VerificationOutcome<T>,
1982 context: &str,
1983) -> anyhow::Result<Verified<T>> {
1984 match outcome {
1985 VerificationOutcome::Verified(verified) => Ok(verified),
1986 VerificationOutcome::Disagreement(_) => {
1987 anyhow::bail!("{context} verification disagreed")
1988 }
1989 VerificationOutcome::Unavailable(_) => {
1990 anyhow::bail!("{context} verification is unavailable")
1991 }
1992 VerificationOutcome::Retryable(_) => {
1993 anyhow::bail!("{context} verification is retryable")
1994 }
1995 VerificationOutcome::LocallyInvalid(_) => {
1996 anyhow::bail!("{context} verification is locally invalid")
1997 }
1998 }
1999}
2000
2001fn validate_transaction_authorization(
2002 authorization: Option<&TransactionAuthorization>,
2003 to: Address,
2004 value: U256,
2005 input: &[u8],
2006) -> anyhow::Result<()> {
2007 match authorization {
2008 None => Ok(()),
2009 Some(TransactionAuthorization::Wrap { weth }) => {
2010 anyhow::ensure!(
2011 to == *weth && !value.is_zero() && input == WETH9::depositCall::SELECTOR,
2012 "Wrap authorization does not match the transaction call"
2013 );
2014 Ok(())
2015 }
2016 Some(TransactionAuthorization::Approve {
2017 token,
2018 router,
2019 amount,
2020 }) => {
2021 let expected = ERC20::approveCall {
2022 spender: *router,
2023 amount: *amount,
2024 }
2025 .abi_encode();
2026 anyhow::ensure!(
2027 to == *token && value.is_zero() && input == expected,
2028 "Approve authorization does not match the transaction call"
2029 );
2030 Ok(())
2031 }
2032 }
2033}
2034
2035fn verification_decision<T>(
2036 verified: &Verified<T>,
2037 height_start: Option<u64>,
2038 height_end: Option<u64>,
2039) -> ExecutionVerificationDecision {
2040 ExecutionVerificationDecision {
2041 read_class: verified.read.as_str(),
2042 height_start,
2043 height_end,
2044 normalized_value_digest: verified.normalized_value_digest.to_string(),
2045 }
2046}
2047
2048fn parse_verified_header(header: &ExecutionVerifiedHeader) -> anyhow::Result<VerifiedBlockHeader> {
2049 Ok(VerifiedBlockHeader {
2050 number: header.number,
2051 hash: B256::from_str(&header.hash).context("Durable finalized header hash is invalid")?,
2052 parent_hash: B256::from_str(&header.parent_hash)
2053 .context("Durable finalized parent hash is invalid")?,
2054 timestamp: header.timestamp,
2055 base_fee_per_gas: header.base_fee_per_gas,
2056 })
2057}
2058
2059fn durable_verified_header(header: &VerifiedBlockHeader) -> ExecutionVerifiedHeader {
2060 ExecutionVerifiedHeader {
2061 number: header.number,
2062 hash: header.hash.to_string(),
2063 parent_hash: header.parent_hash.to_string(),
2064 timestamp: header.timestamp,
2065 base_fee_per_gas: header.base_fee_per_gas,
2066 }
2067}
2068
2069#[derive(Debug, Clone)]
2076struct TransactionExecutor {
2077 http_rpc_client: Arc<BlockchainHttpRpcClient>,
2078 verification: VerificationCoordinator,
2079 manifest_version: String,
2080 manifest_digest: String,
2081 deployment_manifest: Arc<BlockchainDeploymentManifest>,
2082 provider_ids: Vec<String>,
2083 operator_ids: Vec<String>,
2084 failure_domain_ids: Vec<String>,
2085 database: BlockchainCacheDatabase,
2086 signer: Arc<PrivateKeySigner>,
2087 payload_keys: Arc<PayloadKeySet>,
2088 in_flight: Arc<Mutex<Option<InFlightSlot>>>,
2089 wallet_balance: Arc<Mutex<WalletBalance>>,
2090 account_id: AccountId,
2091 wallet_address: Address,
2092 chain_id: u32,
2093 max_fee_per_gas_wei: u64,
2094 base_fee_buffer_bps: u32,
2095 gas_limit: u64,
2096 gas_buffer_bps: u32,
2097 receipt_timeout: Duration,
2098 receipt_max_polls: u32,
2099}
2100
2101impl TransactionExecutor {
2102 #[allow(
2108 clippy::large_futures,
2109 reason = "keeps transaction verification futures inline without additional boxing"
2110 )]
2111 async fn transact(
2112 &self,
2113 to: Address,
2114 value: U256,
2115 input: Bytes,
2116 purpose: TransactionPurpose,
2117 client_order_id: Option<ClientOrderId>,
2118 authorization: TransactionAuthorization,
2119 ) -> anyhow::Result<IncludedTransaction> {
2120 self.claim_slot(purpose)?;
2121 let now_unix_secs = current_unix_secs()?;
2122 let decision_header = match required_verification(
2123 self.verification
2124 .verify_decision_header(now_unix_secs)
2125 .await,
2126 "operator decision header",
2127 ) {
2128 Ok(header) => header,
2129 Err(e) => {
2130 release_preparing_slot(&self.in_flight);
2131 return Err(e);
2132 }
2133 };
2134 let created_block = decision_header.value.number;
2135 let intent = ExecutionIntentInsert {
2136 chain_id: self.chain_id,
2137 wallet_address: self.wallet_address.to_string(),
2138 purpose: purpose.as_str().to_string(),
2139 client_order_id: client_order_id.map(|id| id.to_string()),
2140 trader_id: None,
2141 strategy_id: None,
2142 account_id: None,
2143 instrument_id: None,
2144 pool_address: None,
2145 transaction_to: to.to_string(),
2146 transaction_input: hex::encode_prefixed(&input),
2147 transaction_value: value.to_string(),
2148 amount_in: None,
2149 created_block,
2150 };
2151 let intent = match self.database.reserve_execution_intent(&intent).await {
2152 Ok(intent) => intent,
2153 Err(e) => {
2154 release_preparing_if_reservation_not_committed(&self.in_flight, &e);
2155 return Err(e);
2156 }
2157 };
2158 let prepared = match self
2159 .prepare_and_sign(
2160 intent.id,
2161 intent.created_block,
2162 to,
2163 value,
2164 input,
2165 &authorization,
2166 decision_header,
2167 )
2168 .await
2169 {
2170 Ok(prepared) => prepared,
2171 Err(e) => {
2172 if self
2173 .database
2174 .mark_execution_intent_recoverable(intent.id)
2175 .await
2176 .is_ok()
2177 {
2178 release_preparing_slot(&self.in_flight);
2179 }
2180 return Err(e);
2181 }
2182 };
2183 self.fill_and_persist(&prepared, purpose).await?;
2184
2185 match self.broadcast(&prepared).await? {
2186 BroadcastOutcome::Accepted => {}
2187 BroadcastOutcome::Ambiguous(message) => log::warn!("{message}"),
2188 }
2189
2190 match self.await_finality(&prepared).await? {
2191 InclusionOutcome::Finalized(mut included) => {
2192 included.finality.decisions.extend(
2193 verify_finalized_transaction(
2194 &included,
2195 &intent,
2196 prepared.nonce,
2197 &prepared.raw_tx,
2198 self,
2199 purpose.as_str(),
2200 )
2201 .await?,
2202 );
2203 Ok(included)
2204 }
2205 InclusionOutcome::Reverted(mut included) => {
2206 included.finality.decisions.extend(
2207 verify_finalized_transaction(
2208 &included,
2209 &intent,
2210 prepared.nonce,
2211 &prepared.raw_tx,
2212 self,
2213 purpose.as_str(),
2214 )
2215 .await?,
2216 );
2217 self.commit_verified_finality(&included, TransactionStatus::Reverted, &[])
2218 .await?;
2219 self.database
2220 .mark_execution_event_emitted(prepared.intent_id, "terminal")
2221 .await?;
2222 self.release_slot();
2223 anyhow::bail!("Transaction {} reverted on-chain", included.tx_hash)
2224 }
2225 InclusionOutcome::Pending(message) => anyhow::bail!(message),
2226 }
2227 }
2228
2229 fn claim_slot(&self, purpose: TransactionPurpose) -> anyhow::Result<()> {
2232 let mut slot = self.in_flight.lock();
2233 if let Some(in_flight) = *slot {
2234 return Err(in_flight_limit_error(&in_flight));
2235 }
2236 *slot = Some(InFlightSlot::Preparing(purpose));
2237 Ok(())
2238 }
2239
2240 #[expect(
2243 clippy::too_many_arguments,
2244 reason = "Security-critical transaction fields stay explicit at the signing boundary"
2245 )]
2246 #[allow(
2247 clippy::large_futures,
2248 reason = "keeps transaction verification futures inline without additional boxing"
2249 )]
2250 async fn prepare_and_sign(
2251 &self,
2252 intent_id: i64,
2253 created_block: u64,
2254 to: Address,
2255 value: U256,
2256 input: Bytes,
2257 authorization: &TransactionAuthorization,
2258 decision_header: Verified<VerifiedBlockHeader>,
2259 ) -> anyhow::Result<PreparedTransaction> {
2260 self.prepare_and_sign_with_anchors(
2261 intent_id,
2262 created_block,
2263 to,
2264 value,
2265 input,
2266 None,
2267 Some(authorization),
2268 Some(decision_header),
2269 )
2270 .await
2271 }
2272
2273 #[allow(
2274 clippy::large_futures,
2275 reason = "keeps transaction verification futures inline without additional boxing"
2276 )]
2277 async fn prepare_and_sign_swap(
2278 &self,
2279 intent_id: i64,
2280 created_block: u64,
2281 to: Address,
2282 value: U256,
2283 input: Bytes,
2284 anchors: &SwapQuoteAnchors,
2285 ) -> anyhow::Result<PreparedTransaction> {
2286 self.prepare_and_sign_with_anchors(
2287 intent_id,
2288 created_block,
2289 to,
2290 value,
2291 input,
2292 Some(anchors),
2293 None,
2294 None,
2295 )
2296 .await
2297 }
2298
2299 #[expect(
2300 clippy::too_many_arguments,
2301 reason = "Security-critical transaction fields and verification anchors stay explicit"
2302 )]
2303 #[allow(
2304 clippy::large_futures,
2305 reason = "keeps transaction verification futures inline without additional boxing"
2306 )]
2307 async fn prepare_and_sign_with_anchors(
2308 &self,
2309 intent_id: i64,
2310 created_block: u64,
2311 to: Address,
2312 value: U256,
2313 input: Bytes,
2314 swap_anchors: Option<&SwapQuoteAnchors>,
2315 authorization: Option<&TransactionAuthorization>,
2316 decision_header: Option<Verified<VerifiedBlockHeader>>,
2317 ) -> anyhow::Result<PreparedTransaction> {
2318 let expected_chain_id = u64::from(self.chain_id);
2319 let chain_id_verification = required_verification(
2320 self.verification.verify_chain_id().await,
2321 "pre-sign chain ID",
2322 )?;
2323 let actual_chain_id = chain_id_verification.value;
2324 anyhow::ensure!(
2325 actual_chain_id == expected_chain_id,
2326 "Verified chain ID does not match the transaction chain"
2327 );
2328 let decision_header_verification = if let Some(anchors) = swap_anchors {
2329 required_verification(
2330 self.verification.verify_block(anchors.state.number).await,
2331 "pre-sign swap decision header reread",
2332 )?
2333 } else {
2334 match decision_header {
2335 Some(verified) => verified,
2336 None => {
2337 let now_unix_secs = current_unix_secs()?;
2338 required_verification(
2339 self.verification
2340 .verify_decision_header(now_unix_secs)
2341 .await,
2342 "pre-sign decision header",
2343 )?
2344 }
2345 }
2346 };
2347 let decision_header = decision_header_verification.value;
2348
2349 if let Some(anchors) = swap_anchors {
2350 anyhow::ensure!(
2351 decision_header == anchors.state,
2352 "Verified swap decision header changed before signing"
2353 );
2354 }
2355 let decision_ancestry = self.verify_decision_ancestry(decision_header).await?;
2356 validate_transaction_authorization(authorization, to, value, &input)?;
2357 let deployment_verification = required_verification(
2358 self.verification
2359 .verify_deployment_manifest(&self.deployment_manifest, decision_header.number)
2360 .await,
2361 "pre-sign deployment manifest",
2362 )?;
2363 let authorization_decisions = match authorization {
2364 Some(authorization) => {
2365 self.verify_transaction_authorization(authorization, decision_header.number)
2366 .await?
2367 }
2368 None => Vec::new(),
2369 };
2370 let base_fee_per_gas_wei = decision_header.base_fee_per_gas.ok_or_else(|| {
2371 anyhow::anyhow!(
2372 "Verified decision block {} has no base fee",
2373 decision_header.number
2374 )
2375 })?;
2376 let priority_fee_verification = required_verification(
2377 self.verification.verify_priority_fee().await,
2378 "pre-sign priority fee",
2379 )?;
2380 let priority_fee_per_gas_wei = priority_fee_verification.value;
2381 let (max_fee_per_gas, max_priority_fee_per_gas) = derive_fees(
2382 base_fee_per_gas_wei,
2383 priority_fee_per_gas_wei,
2384 self.base_fee_buffer_bps,
2385 u128::from(self.max_fee_per_gas_wei),
2386 )?;
2387 let gas_estimate_verification = required_verification(
2388 self.verification
2389 .verify_gas_estimate(
2390 &self.wallet_address,
2391 &to,
2392 value,
2393 &input,
2394 decision_header.number,
2395 )
2396 .await,
2397 "pre-sign gas estimate",
2398 )?;
2399 let gas_estimate = gas_estimate_verification.value;
2400 let gas_limit = derive_gas_limit(gas_estimate, self.gas_buffer_bps, self.gas_limit)?;
2401 let max_gas_cost = U256::from(gas_limit)
2402 .checked_mul(U256::from(max_fee_per_gas))
2403 .ok_or_else(|| anyhow::anyhow!("Maximum gas cost overflow"))?;
2404 let max_transaction_cost = value
2405 .checked_add(max_gas_cost)
2406 .ok_or_else(|| anyhow::anyhow!("Maximum transaction cost overflow"))?;
2407 let native_balance_verification = required_verification(
2408 self.verification
2409 .verify_balance(&self.wallet_address, decision_header.number)
2410 .await,
2411 "pre-sign native balance",
2412 )?;
2413 let native_balance = native_balance_verification.value;
2414
2415 if native_balance < max_transaction_cost {
2416 anyhow::bail!(
2417 "Native currency balance {native_balance} wei is below maximum transaction cost {max_transaction_cost} wei"
2418 );
2419 }
2420 let decision_height = Some(decision_header.number);
2421 let mut decisions = vec![
2422 verification_decision(&chain_id_verification, None, None),
2423 verification_decision(
2424 &decision_header_verification,
2425 decision_height,
2426 decision_height,
2427 ),
2428 verification_decision(&deployment_verification, decision_height, decision_height),
2429 verification_decision(&priority_fee_verification, None, None),
2430 verification_decision(&gas_estimate_verification, decision_height, decision_height),
2431 verification_decision(
2432 &native_balance_verification,
2433 decision_height,
2434 decision_height,
2435 ),
2436 ];
2437 decisions.extend(decision_ancestry);
2438 decisions.extend(authorization_decisions);
2439 if let Some(anchors) = swap_anchors {
2440 decisions.extend(self.verify_swap_anchors_before_sign(anchors).await?);
2441 decisions.extend(anchors.precondition_decisions.iter().cloned());
2442 } else {
2443 decisions.extend(self.verify_pre_sign_header_fence(decision_header).await?);
2444 }
2445 let canonical_nonce_verification = required_verification(
2446 self.verification
2447 .verify_transaction_count(&self.wallet_address, decision_header.number)
2448 .await,
2449 "pre-sign canonical nonce reread",
2450 )?;
2451 let pending_nonce_verification = required_verification(
2452 self.verification
2453 .verify_pending_transaction_count(&self.wallet_address)
2454 .await,
2455 "pre-sign pending nonce reread",
2456 )?;
2457 anyhow::ensure!(
2458 pending_nonce_verification.value == canonical_nonce_verification.value,
2459 "Pending nonce does not match the verified canonical nonce"
2460 );
2461 let nonce = canonical_nonce_verification.value;
2462 decisions.push(verification_decision(
2463 &canonical_nonce_verification,
2464 decision_height,
2465 decision_height,
2466 ));
2467 decisions.push(verification_decision(
2468 &pending_nonce_verification,
2469 None,
2470 None,
2471 ));
2472 let tx = build_eip1559_transaction(
2473 expected_chain_id,
2474 nonce,
2475 gas_limit,
2476 max_fee_per_gas,
2477 max_priority_fee_per_gas,
2478 to,
2479 value,
2480 input,
2481 );
2482 let wallet_address = self.wallet_address.to_string();
2483 self.database
2484 .assign_execution_intent_nonce_verified(&ExecutionNonceAssignment {
2485 intent_id,
2486 chain_id: self.chain_id,
2487 wallet_address: &wallet_address,
2488 nonce,
2489 manifest_version: &self.manifest_version,
2490 manifest_digest: &self.manifest_digest,
2491 provider_ids: &self.provider_ids,
2492 operator_ids: &self.operator_ids,
2493 failure_domain_ids: &self.failure_domain_ids,
2494 decisions: &decisions,
2495 })
2496 .await?;
2497 let payload_lease = self
2498 .database
2499 .acquire_execution_payload_lease(&self.payload_keys)
2500 .await?;
2501 let (tx_hash, raw_tx) = sign_eip1559_transaction(tx, &self.signer).await?;
2502
2503 Ok(PreparedTransaction {
2504 intent_id,
2505 created_block,
2506 nonce,
2507 tx_hash,
2508 raw_tx,
2509 payload_lease: Some(payload_lease),
2510 })
2511 }
2512
2513 async fn verify_pre_sign_header_fence(
2514 &self,
2515 target: VerifiedBlockHeader,
2516 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
2517 let checkpoint = required_verification(
2518 self.verification.verify_checkpoint().await,
2519 "pre-sign checkpoint reread",
2520 )?;
2521 let header = required_verification(
2522 self.verification.verify_block(target.number).await,
2523 "pre-sign decision header reread",
2524 )?;
2525 anyhow::ensure!(
2526 header.value == target,
2527 "Decision header changed before signing"
2528 );
2529 Ok(vec![
2530 verification_decision(
2531 &checkpoint,
2532 Some(checkpoint.value.number),
2533 Some(checkpoint.value.number),
2534 ),
2535 verification_decision(&header, Some(target.number), Some(target.number)),
2536 ])
2537 }
2538
2539 async fn verify_decision_ancestry(
2540 &self,
2541 target: VerifiedBlockHeader,
2542 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
2543 let checkpoint = required_verification(
2544 self.verification.verify_checkpoint().await,
2545 "pre-sign checkpoint",
2546 )?;
2547 anyhow::ensure!(
2548 checkpoint.value.number <= target.number,
2549 "Pre-sign decision header precedes the trusted checkpoint"
2550 );
2551 let mut decisions = vec![verification_decision(
2552 &checkpoint,
2553 Some(checkpoint.value.number),
2554 Some(checkpoint.value.number),
2555 )];
2556 let wallet_address = self.wallet_address.to_string();
2557 let position = self
2558 .database
2559 .load_execution_verification_position(
2560 self.chain_id,
2561 &wallet_address,
2562 &self.manifest_version,
2563 &self.manifest_digest,
2564 )
2565 .await?
2566 .ok_or_else(|| anyhow::anyhow!("Execution verification ledger is not initialized"))?;
2567 let durable_tip = parse_verified_header(&position.finalized_tip)?;
2568 anyhow::ensure!(
2569 durable_tip.number >= checkpoint.value.number && durable_tip.number <= target.number,
2570 "Pre-sign decision header does not extend the durable finalized header tip"
2571 );
2572 let durable_tip_verification = required_verification(
2573 self.verification.verify_block(durable_tip.number).await,
2574 "pre-sign durable finalized tip",
2575 )?;
2576 anyhow::ensure!(
2577 durable_tip_verification.value == durable_tip,
2578 "Durable finalized header tip conflicts with independent sources"
2579 );
2580
2581 if durable_tip != checkpoint.value {
2582 decisions.push(verification_decision(
2583 &durable_tip_verification,
2584 Some(durable_tip.number),
2585 Some(durable_tip.number),
2586 ));
2587 }
2588 let mut cursor = durable_tip;
2589 while cursor.number < target.number {
2590 let end = cursor.number.saturating_add(4_096).min(target.number);
2591 let start = cursor.number.saturating_add(1);
2592 let ancestry = required_verification(
2593 self.verification.verify_header_window(cursor, end).await,
2594 "pre-sign decision ancestry",
2595 )?;
2596 cursor = *ancestry
2597 .value
2598 .last()
2599 .expect("nonempty decision ancestry advances the cursor");
2600 decisions.push(verification_decision(&ancestry, Some(start), Some(end)));
2601 }
2602 anyhow::ensure!(
2603 cursor == target,
2604 "Pre-sign decision header conflicts with its trusted ancestry"
2605 );
2606 Ok(decisions)
2607 }
2608
2609 async fn verify_transaction_authorization(
2610 &self,
2611 authorization: &TransactionAuthorization,
2612 block: u64,
2613 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
2614 match *authorization {
2615 TransactionAuthorization::Wrap { weth } => {
2616 let call = ERC20::balanceOfCall {
2617 account: self.wallet_address,
2618 }
2619 .abi_encode();
2620 let balance = required_verification(
2621 self.verification
2622 .verify_decoded_call(None, &weth, U256::ZERO, &call, block, |result| {
2623 ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into)
2624 })
2625 .await,
2626 "pre-sign wrapped token probe",
2627 )?;
2628 Ok(vec![verification_decision(
2629 &balance,
2630 Some(block),
2631 Some(block),
2632 )])
2633 }
2634 TransactionAuthorization::Approve {
2635 token,
2636 router,
2637 amount,
2638 } => {
2639 let allowance_call = ERC20::allowanceCall {
2640 owner: self.wallet_address,
2641 spender: router,
2642 }
2643 .abi_encode();
2644 let allowance = required_verification(
2645 self.verification
2646 .verify_decoded_call(
2647 None,
2648 &token,
2649 U256::ZERO,
2650 &allowance_call,
2651 block,
2652 |result| {
2653 ERC20::allowanceCall::abi_decode_returns(result).map_err(Into::into)
2654 },
2655 )
2656 .await,
2657 "pre-sign router allowance",
2658 )?;
2659 anyhow::ensure!(
2660 allowance.value.is_zero() || amount.is_zero(),
2661 "Router allowance for token {token} is already {}; approve zero before setting a new nonzero allowance",
2662 allowance.value
2663 );
2664
2665 let approve_call = ERC20::approveCall {
2666 spender: router,
2667 amount,
2668 }
2669 .abi_encode();
2670 let simulation = required_verification(
2671 self.verification
2672 .verify_decoded_simulation(
2673 &self.wallet_address,
2674 &token,
2675 U256::ZERO,
2676 &approve_call,
2677 block,
2678 |result| {
2679 if result.is_empty() {
2680 Ok(true)
2681 } else {
2682 ERC20::approveCall::abi_decode_returns_validate(result)
2683 .map_err(Into::into)
2684 }
2685 },
2686 )
2687 .await,
2688 "pre-sign approval simulation",
2689 )?;
2690
2691 match &simulation.value {
2692 VerifiedSimulation::Succeeded(true) => {}
2693 VerifiedSimulation::Succeeded(false) => {
2694 anyhow::bail!("ERC-20 approve returned false for token {token}")
2695 }
2696 VerifiedSimulation::Denied => {
2697 anyhow::bail!("ERC-20 approve simulation reverted for token {token}")
2698 }
2699 }
2700 Ok(vec![
2701 verification_decision(&allowance, Some(block), Some(block)),
2702 verification_decision(&simulation, Some(block), Some(block)),
2703 ])
2704 }
2705 }
2706 }
2707
2708 async fn verify_swap_anchors_before_sign(
2709 &self,
2710 anchors: &SwapQuoteAnchors,
2711 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
2712 let checkpoint = required_verification(
2713 self.verification.verify_checkpoint().await,
2714 "pre-sign checkpoint reread",
2715 )?;
2716 let watermark = required_verification(
2717 self.verification
2718 .verify_block(anchors.watermark.number)
2719 .await,
2720 "pre-sign profiler watermark reread",
2721 )?;
2722 anyhow::ensure!(
2723 watermark.value == anchors.watermark,
2724 "Pool state header changed before signing"
2725 );
2726 let ancestry = required_verification(
2727 self.verification
2728 .verify_header_window(watermark.value, anchors.state.number)
2729 .await,
2730 "pre-sign profiler ancestry reread",
2731 )?;
2732
2733 if let Some(last) = ancestry.value.last() {
2734 anyhow::ensure!(
2735 *last == anchors.state,
2736 "Swap decision header changed in the pre-sign ancestry reread"
2737 );
2738 } else {
2739 anyhow::ensure!(
2740 watermark.value == anchors.state,
2741 "Empty pre-sign ancestry does not end at the swap decision header"
2742 );
2743 }
2744 let quote = match anchors.quote_kind {
2745 SwapQuoteKind::ExactInput(amount_in) => required_verification(
2746 self.verification
2747 .verify_quote_exact_input_single(
2748 &anchors.quote_contract,
2749 anchors.token_in,
2750 anchors.token_out,
2751 amount_in,
2752 anchors.fee,
2753 anchors.state.number,
2754 )
2755 .await,
2756 "pre-sign exact-input quote reread",
2757 )?,
2758 SwapQuoteKind::ExactOutput(amount_out) => required_verification(
2759 self.verification
2760 .verify_quote_exact_output_single(
2761 &anchors.quote_contract,
2762 anchors.token_in,
2763 anchors.token_out,
2764 amount_out,
2765 anchors.fee,
2766 anchors.state.number,
2767 )
2768 .await,
2769 "pre-sign exact-output quote reread",
2770 )?,
2771 };
2772 anyhow::ensure!(
2773 quote.value == anchors.quote,
2774 "Independent swap quote changed before signing"
2775 );
2776 Ok(vec![
2777 verification_decision(
2778 &checkpoint,
2779 Some(checkpoint.value.number),
2780 Some(checkpoint.value.number),
2781 ),
2782 verification_decision(
2783 &watermark,
2784 Some(anchors.watermark.number),
2785 Some(anchors.watermark.number),
2786 ),
2787 verification_decision(
2788 &ancestry,
2789 Some(anchors.watermark.number),
2790 Some(anchors.state.number),
2791 ),
2792 verification_decision(
2793 "e,
2794 Some(anchors.state.number),
2795 Some(anchors.state.number),
2796 ),
2797 ])
2798 }
2799
2800 async fn fill_and_persist(
2808 &self,
2809 prepared: &PreparedTransaction,
2810 purpose: TransactionPurpose,
2811 ) -> anyhow::Result<()> {
2812 {
2813 let mut slot = self.in_flight.lock();
2814 *slot = Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
2815 intent_id: prepared.intent_id,
2816 nonce: prepared.nonce,
2817 tx_hash: prepared.tx_hash,
2818 purpose,
2819 }));
2820 }
2821
2822 let tx_hash = prepared.tx_hash;
2823 let transaction_hash = tx_hash.to_string();
2824 let policy = self.payload_policy();
2825 let intent = self
2826 .database
2827 .get_execution_intent(prepared.intent_id)
2828 .await?;
2829 authenticate_payload_identity(
2830 &prepared.raw_tx,
2831 &intent,
2832 &transaction_hash,
2833 self.chain_id,
2834 policy,
2835 )
2836 .with_context(|| format!("Newly signed transaction {tx_hash} failed authentication"))?;
2837
2838 self.database
2839 .reserve_execution_payload_seal(&self.payload_keys)
2840 .await?;
2841 let context = payload_context_identity(
2842 &intent,
2843 &transaction_hash,
2844 self.chain_id,
2845 self.payload_keys.deployment_id(),
2846 )?;
2847 let envelope = self.payload_keys.seal(&prepared.raw_tx, &context)?;
2848 let row = self
2849 .database
2850 .add_execution_transaction_envelope(
2851 prepared.intent_id,
2852 self.chain_id,
2853 &transaction_hash,
2854 &envelope,
2855 )
2856 .await
2857 .map_err(|e| {
2858 anyhow::anyhow!(
2859 "Failed to persist transaction {tx_hash}: {e}; the in-flight slot stays occupied"
2860 )
2861 })?;
2862 let stored = open_execution_payload(
2863 &self.payload_keys,
2864 policy,
2865 &intent,
2866 &row,
2867 "initial persistence",
2868 )?;
2869 anyhow::ensure!(
2870 stored == prepared.raw_tx,
2871 "Persisted transaction {tx_hash} does not match the signed bytes"
2872 );
2873 Ok(())
2874 }
2875
2876 fn payload_policy(&self) -> PayloadPolicy {
2877 PayloadPolicy {
2878 chain_id: self.chain_id,
2879 signer: self.wallet_address,
2880 gas_limit: self.gas_limit,
2881 max_fee_per_gas: self.max_fee_per_gas_wei,
2882 }
2883 }
2884
2885 #[allow(
2886 clippy::large_futures,
2887 reason = "keeps transaction verification futures inline without additional boxing"
2888 )]
2889 async fn authorize_rebroadcast(
2890 &self,
2891 prepared: &PreparedTransaction,
2892 intent: &ExecutionIntentRow,
2893 purpose: TransactionPurpose,
2894 ) -> anyhow::Result<ReconciliationAuthorization> {
2895 let now_unix_secs = current_unix_secs()?;
2896 let decision_header = required_verification(
2897 self.verification
2898 .verify_decision_header(now_unix_secs)
2899 .await,
2900 "rebroadcast decision header",
2901 )?;
2902 let block = decision_header.value.number;
2903 let mut decisions = vec![verification_decision(
2904 &decision_header,
2905 Some(block),
2906 Some(block),
2907 )];
2908 decisions.extend(self.verify_decision_ancestry(decision_header.value).await?);
2909 let deployment = required_verification(
2910 self.verification
2911 .verify_deployment_manifest(&self.deployment_manifest, block)
2912 .await,
2913 "rebroadcast deployment manifest",
2914 )?;
2915 decisions.push(verification_decision(&deployment, Some(block), Some(block)));
2916 let canonical_nonce = required_verification(
2917 self.verification
2918 .verify_transaction_count(&self.wallet_address, block)
2919 .await,
2920 "rebroadcast canonical nonce",
2921 )?;
2922 decisions.push(verification_decision(
2923 &canonical_nonce,
2924 Some(block),
2925 Some(block),
2926 ));
2927 let pending_nonce = required_verification(
2928 self.verification
2929 .verify_reconciliation_pending_transaction_count(
2930 &self.wallet_address,
2931 prepared.nonce,
2932 )
2933 .await,
2934 "rebroadcast pending nonce",
2935 )?;
2936 decisions.push(verification_decision(&pending_nonce, None, None));
2937 let receipt_absence = required_verification(
2938 self.verification
2939 .verify_receipt_absence(&prepared.tx_hash)
2940 .await,
2941 "rebroadcast receipt absence",
2942 )?;
2943 decisions.push(verification_decision(&receipt_absence, None, None));
2944
2945 let next_nonce = prepared
2946 .nonce
2947 .checked_add(1)
2948 .ok_or_else(|| anyhow::anyhow!("Owned signer nonce overflow"))?;
2949 anyhow::ensure!(
2950 (prepared.nonce..=next_nonce).contains(&pending_nonce.value),
2951 "Pending nonce {} is outside the owned reconciliation range {}..={next_nonce}",
2952 pending_nonce.value,
2953 prepared.nonce
2954 );
2955 anyhow::ensure!(
2956 (prepared.nonce..=next_nonce).contains(&canonical_nonce.value),
2957 "Canonical nonce {} is outside the owned reconciliation range {}..={next_nonce}",
2958 canonical_nonce.value,
2959 prepared.nonce
2960 );
2961
2962 if !receipt_absence.value {
2963 self.persist_rebroadcast_decisions(intent.id, prepared.nonce, &decisions)
2964 .await?;
2965 return Ok(ReconciliationAuthorization::Retain);
2966 }
2967
2968 if canonical_nonce.value == next_nonce {
2969 self.persist_rebroadcast_decisions(intent.id, prepared.nonce, &decisions)
2970 .await?;
2971 return Ok(ReconciliationAuthorization::ScanReplacement(
2972 decision_header.value,
2973 ));
2974 }
2975
2976 let (to, input, value) = persisted_call_fields(intent)?;
2977 let authorized = match purpose {
2978 TransactionPurpose::Wrap => {
2979 let simulation = required_verification(
2980 self.verification
2981 .verify_decoded_simulation(
2982 &self.wallet_address,
2983 &to,
2984 value,
2985 &input,
2986 block,
2987 |result| Ok(result.is_empty()),
2988 )
2989 .await,
2990 "rebroadcast wrap simulation",
2991 )?;
2992 let authorized = matches!(&simulation.value, VerifiedSimulation::Succeeded(true));
2993 decisions.push(verification_decision(&simulation, Some(block), Some(block)));
2994 authorized
2995 }
2996 TransactionPurpose::Approve => {
2997 let simulation = required_verification(
2998 self.verification
2999 .verify_decoded_simulation(
3000 &self.wallet_address,
3001 &to,
3002 value,
3003 &input,
3004 block,
3005 |result| {
3006 if result.is_empty() {
3007 Ok(true)
3008 } else {
3009 ERC20::approveCall::abi_decode_returns_validate(result)
3010 .map_err(Into::into)
3011 }
3012 },
3013 )
3014 .await,
3015 "rebroadcast approve simulation",
3016 )?;
3017 let authorized = matches!(&simulation.value, VerifiedSimulation::Succeeded(true));
3018 decisions.push(verification_decision(&simulation, Some(block), Some(block)));
3019 authorized
3020 }
3021 TransactionPurpose::Swap => {
3022 let call = UniswapV3SwapRouter::exactInputSingleCall::abi_decode(&input)
3023 .with_context(|| "persisted swap calldata is invalid")?;
3024
3025 if U256::from(decision_header.value.timestamp) > call.params.deadline {
3026 false
3027 } else {
3028 let simulation = required_verification(
3029 self.verification
3030 .verify_decoded_simulation(
3031 &self.wallet_address,
3032 &to,
3033 value,
3034 &input,
3035 block,
3036 |result| {
3037 UniswapV3SwapRouter::exactInputSingleCall::abi_decode_returns(
3038 result,
3039 )
3040 .map_err(Into::into)
3041 },
3042 )
3043 .await,
3044 "rebroadcast swap simulation",
3045 )?;
3046 let authorized = match &simulation.value {
3047 VerifiedSimulation::Succeeded(amount_out) => {
3048 *amount_out >= call.params.amountOutMinimum
3049 }
3050 VerifiedSimulation::Denied => false,
3051 };
3052 decisions.push(verification_decision(&simulation, Some(block), Some(block)));
3053 authorized
3054 }
3055 }
3056 };
3057 self.persist_rebroadcast_decisions(intent.id, prepared.nonce, &decisions)
3058 .await?;
3059 Ok(if authorized {
3060 ReconciliationAuthorization::Rebroadcast
3061 } else {
3062 ReconciliationAuthorization::Retain
3063 })
3064 }
3065
3066 async fn scan_canonical_replacement(
3067 &self,
3068 intent: &ExecutionIntentRow,
3069 nonce: u64,
3070 head: VerifiedBlockHeader,
3071 authenticated_payloads: &HashMap<B256, Vec<u8>>,
3072 ) -> anyhow::Result<Option<(B256, Vec<u8>)>> {
3073 let wallet_address = self.wallet_address.to_string();
3074 let cursor = self
3075 .database
3076 .load_execution_replacement_cursor(
3077 intent.id,
3078 self.chain_id,
3079 &wallet_address,
3080 nonce,
3081 &self.manifest_digest,
3082 )
3083 .await?;
3084 let start = cursor.as_ref().map_or(intent.created_block, |header| {
3085 header.number.saturating_add(1)
3086 });
3087 anyhow::ensure!(
3088 start <= head.number,
3089 "Canonical nonce advanced without an authenticated signer transaction in the scanned canonical range"
3090 );
3091 let scan_range = replacement_scan_range(start, head.number)?;
3092 let end = *scan_range.end();
3093 let mut decisions = Vec::new();
3094 let mut blocks = Vec::new();
3095
3096 if let Some(cursor) = cursor.as_ref() {
3097 let parent = parse_verified_header(cursor)?;
3098 let window = required_verification(
3099 self.verification
3100 .verify_replacement_window(parent, end)
3101 .await,
3102 "canonical replacement window",
3103 )?;
3104 decisions.push(verification_decision(&window, Some(start), Some(end)));
3105 blocks = window.value;
3106 } else {
3107 let start_header = required_verification(
3108 self.verification.verify_block(start).await,
3109 "canonical replacement start header",
3110 )?;
3111 decisions.push(verification_decision(
3112 &start_header,
3113 Some(start),
3114 Some(start),
3115 ));
3116 let start_block = required_verification(
3117 self.verification.verify_replacement_block(start).await,
3118 "canonical replacement start block",
3119 )?;
3120 anyhow::ensure!(
3121 VerifiedBlockHeader::from(start_block.value.clone()) == start_header.value,
3122 "Replacement block conflicts with its canonical header"
3123 );
3124 decisions.push(verification_decision(
3125 &start_block,
3126 Some(start),
3127 Some(start),
3128 ));
3129 blocks.push(start_block.value);
3130
3131 if end > start {
3132 let window = required_verification(
3133 self.verification
3134 .verify_replacement_window(start_header.value, end)
3135 .await,
3136 "canonical replacement window",
3137 )?;
3138 decisions.push(verification_decision(&window, Some(start + 1), Some(end)));
3139 blocks.extend(window.value);
3140 }
3141 }
3142
3143 let scanned_tip = blocks
3144 .last()
3145 .map(|block| VerifiedBlockHeader::from(block.clone()))
3146 .ok_or_else(|| anyhow::anyhow!("Verified replacement scan returned no blocks"))?;
3147 if end == head.number {
3148 anyhow::ensure!(
3149 scanned_tip == head,
3150 "Replacement scan tip conflicts with the verified canonical head"
3151 );
3152 }
3153 let mut candidates = blocks
3154 .iter()
3155 .flat_map(|block| block.transactions.iter())
3156 .filter(|transaction| {
3157 transaction.from == self.wallet_address && transaction.nonce == nonce
3158 });
3159 let candidate = candidates.next();
3160 anyhow::ensure!(
3161 candidates.next().is_none(),
3162 "Canonical replacement scan found duplicate signer-nonce transactions"
3163 );
3164
3165 let finalized_cursor = self
3166 .database
3167 .load_execution_verified_header(
3168 self.chain_id,
3169 &wallet_address,
3170 end,
3171 &self.manifest_digest,
3172 )
3173 .await?;
3174
3175 if let Some(cursor) = finalized_cursor.as_ref() {
3176 anyhow::ensure!(
3177 parse_verified_header(cursor)? == scanned_tip,
3178 "Replacement scan conflicts with the durable finalized header ledger"
3179 );
3180 }
3181
3182 let mut mismatch = None;
3183 let matched = candidate.and_then(|transaction| {
3184 let Some(raw_transaction) = authenticated_payloads.get(&transaction.hash).cloned()
3185 else {
3186 mismatch = Some(anyhow::anyhow!(
3187 "Canonical signer-nonce transaction {} has no authenticated retained payload",
3188 transaction.hash
3189 ));
3190 return None;
3191 };
3192
3193 if let Err(e) = validate_rpc_transaction_matches_payload(transaction, &raw_transaction)
3194 {
3195 mismatch = Some(e.context(format!(
3196 "Canonical signer-nonce transaction {} failed authenticated payload validation",
3197 transaction.hash
3198 )));
3199 return None;
3200 }
3201 Some((transaction.hash, raw_transaction))
3202 });
3203 let matched_hash = matched.as_ref().map(|(hash, _)| hash.to_string());
3204 self.database
3205 .record_execution_replacement_scan(&ExecutionReplacementScan {
3206 intent_id: intent.id,
3207 chain_id: self.chain_id,
3208 wallet_address: &wallet_address,
3209 nonce,
3210 finalized_cursor: finalized_cursor.as_ref(),
3211 matched_transaction_hash: matched_hash.as_deref(),
3212 manifest_version: &self.manifest_version,
3213 manifest_digest: &self.manifest_digest,
3214 provider_ids: &self.provider_ids,
3215 operator_ids: &self.operator_ids,
3216 failure_domain_ids: &self.failure_domain_ids,
3217 decisions: &decisions,
3218 })
3219 .await?;
3220
3221 if let Some(e) = mismatch {
3222 return Err(e);
3223 }
3224
3225 if matched.is_none() && end == head.number {
3226 anyhow::bail!(
3227 "Canonical nonce advanced without an authenticated signer transaction in the canonical range"
3228 );
3229 }
3230 Ok(matched)
3231 }
3232
3233 async fn persist_rebroadcast_decisions(
3234 &self,
3235 intent_id: i64,
3236 nonce: u64,
3237 decisions: &[ExecutionVerificationDecision],
3238 ) -> anyhow::Result<()> {
3239 let wallet_address = self.wallet_address.to_string();
3240 self.database
3241 .record_execution_verification_batch(&ExecutionVerificationBatch {
3242 intent_id,
3243 chain_id: self.chain_id,
3244 wallet_address: &wallet_address,
3245 nonce,
3246 decision_class: "rebroadcast",
3247 manifest_version: &self.manifest_version,
3248 manifest_digest: &self.manifest_digest,
3249 provider_ids: &self.provider_ids,
3250 operator_ids: &self.operator_ids,
3251 failure_domain_ids: &self.failure_domain_ids,
3252 decisions,
3253 })
3254 .await
3255 }
3256
3257 async fn broadcast(&self, prepared: &PreparedTransaction) -> anyhow::Result<BroadcastOutcome> {
3258 let tx_hash = prepared.tx_hash;
3259
3260 self.database
3261 .record_execution_status(
3262 prepared.intent_id,
3263 &tx_hash.to_string(),
3264 TransactionStatus::Broadcast,
3265 None,
3266 None,
3267 None,
3268 None,
3269 None,
3270 )
3271 .await
3272 .map_err(|e| {
3273 anyhow::anyhow!(
3274 "Failed to persist broadcast attempt for transaction {tx_hash}: {e}; the in-flight slot stays occupied"
3275 )
3276 })?;
3277
3278 match self
3279 .http_rpc_client
3280 .send_raw_transaction(&prepared.raw_tx, &tx_hash)
3281 .await
3282 {
3283 Ok(broadcast_hash) => {
3284 if broadcast_hash != tx_hash {
3285 return Ok(BroadcastOutcome::Ambiguous(format!(
3289 "Broadcast of transaction {tx_hash} returned a differing hash {broadcast_hash}; the persisted record reconciles instead of rebroadcasting"
3290 )));
3291 }
3292 Ok(BroadcastOutcome::Accepted)
3293 }
3294 Err(BroadcastError::TimeoutAfterSend) => Ok(BroadcastOutcome::Ambiguous(format!(
3295 "Broadcast of transaction {tx_hash} timed out after send; the persisted record reconciles instead of rebroadcasting"
3296 ))),
3297 Err(BroadcastError::Failed(message)) => {
3298 Ok(BroadcastOutcome::Ambiguous(format!(
3302 "Broadcast of transaction {tx_hash} failed ambiguously ({message}); the persisted record reconciles instead of rebroadcasting"
3303 )))
3304 }
3305 Err(error @ BroadcastError::Rejected { .. }) => {
3306 Ok(BroadcastOutcome::Ambiguous(format!(
3307 "Broadcast of transaction {tx_hash} was rejected ({error}); the signed hash remains occupied until canonical nonce reconciliation"
3308 )))
3309 }
3310 }
3311 }
3312
3313 #[allow(
3315 clippy::large_futures,
3316 reason = "keeps transaction verification futures inline without additional boxing"
3317 )]
3318 async fn await_finality(
3319 &self,
3320 prepared: &PreparedTransaction,
3321 ) -> anyhow::Result<InclusionOutcome> {
3322 let tx_hash = prepared.tx_hash;
3323 let deadline = tokio::time::Instant::now() + self.receipt_timeout;
3324
3325 for attempt in 0..self.receipt_max_polls {
3326 if tokio::time::Instant::now() >= deadline {
3327 break;
3328 }
3329
3330 if attempt > 0 {
3331 tokio::time::sleep(RECEIPT_POLL_INTERVAL).await;
3332 }
3333
3334 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
3335 let receipt_result =
3336 match tokio::time::timeout(remaining, self.verification.verify_receipt(&tx_hash))
3337 .await
3338 {
3339 Ok(result) => result,
3340 Err(_) => break,
3341 };
3342
3343 match receipt_result {
3344 VerificationOutcome::Verified(verified_receipt) => {
3345 let receipt = &verified_receipt.value;
3346 let canonical_verification = required_verification(
3347 self.verification.verify_block(receipt.block_number).await,
3348 "receipt inclusion header",
3349 )?;
3350 let canonical = canonical_verification.value;
3351
3352 if canonical.hash != receipt.block_hash {
3353 continue;
3354 }
3355
3356 if let Some(mut finality) = self.receipt_is_stably_finalized(receipt).await? {
3357 finality.decisions.insert(
3358 0,
3359 verification_decision(
3360 &canonical_verification,
3361 Some(receipt.block_number),
3362 Some(receipt.block_number),
3363 ),
3364 );
3365 finality.decisions.insert(
3366 0,
3367 verification_decision(
3368 &verified_receipt,
3369 Some(receipt.block_number),
3370 Some(receipt.block_number),
3371 ),
3372 );
3373 let included = IncludedTransaction {
3374 intent_id: prepared.intent_id,
3375 nonce: prepared.nonce,
3376 tx_hash,
3377 block_number: receipt.block_number,
3378 receipt: receipt.clone(),
3379 finality,
3380 };
3381 return if receipt.status {
3382 Ok(InclusionOutcome::Finalized(included))
3383 } else {
3384 Ok(InclusionOutcome::Reverted(included))
3385 };
3386 }
3387 }
3388 VerificationOutcome::Retryable(_) => {}
3389 VerificationOutcome::Disagreement(_) => {
3390 return Ok(InclusionOutcome::Pending(format!(
3391 "Receipt verification disagreed for transaction {tx_hash}; the intent stays occupied for reconciliation"
3392 )));
3393 }
3394 VerificationOutcome::Unavailable(_) => {
3395 log::warn!(
3396 "Finality poll {}/{} for transaction {tx_hash} was unavailable",
3397 attempt + 1,
3398 self.receipt_max_polls
3399 );
3400 }
3401 VerificationOutcome::LocallyInvalid(_) => {
3402 anyhow::bail!(
3403 "Receipt verification is locally invalid for transaction {tx_hash}"
3404 );
3405 }
3406 }
3407 }
3408
3409 self.database
3410 .record_execution_status(
3411 prepared.intent_id,
3412 &tx_hash.to_string(),
3413 TransactionStatus::Dropped,
3414 None,
3415 None,
3416 None,
3417 None,
3418 None,
3419 )
3420 .await?;
3421 Ok(InclusionOutcome::Pending(format!(
3422 "Timed out awaiting finality of transaction {tx_hash}; the intent stays occupied for reconciliation"
3423 )))
3424 }
3425
3426 async fn receipt_is_stably_finalized(
3427 &self,
3428 receipt: &RpcTransactionReceipt,
3429 ) -> anyhow::Result<Option<StableFinality>> {
3430 let finalized_verification = match self.verification.verify_finalized_header().await {
3431 VerificationOutcome::Verified(verified) => verified,
3432 VerificationOutcome::Retryable(_) | VerificationOutcome::Unavailable(_) => {
3433 return Ok(None);
3434 }
3435 VerificationOutcome::Disagreement(_) => {
3436 anyhow::bail!("Finalized header verification disagreed")
3437 }
3438 VerificationOutcome::LocallyInvalid(_) => {
3439 anyhow::bail!("Finalized header verification is locally invalid")
3440 }
3441 };
3442 let finalized = finalized_verification.value;
3443 if finalized.number < receipt.block_number {
3444 return Ok(None);
3445 }
3446
3447 let checkpoint_verification = required_verification(
3448 self.verification.verify_checkpoint().await,
3449 "finality checkpoint reread",
3450 )?;
3451 let checkpoint = checkpoint_verification.value;
3452 let mut decisions = vec![verification_decision(
3453 &checkpoint_verification,
3454 Some(checkpoint.number),
3455 Some(checkpoint.number),
3456 )];
3457 let position = self
3458 .database
3459 .load_execution_verification_position(
3460 self.chain_id,
3461 &self.wallet_address.to_string(),
3462 &self.manifest_version,
3463 &self.manifest_digest,
3464 )
3465 .await?
3466 .ok_or_else(|| anyhow::anyhow!("Execution verification ledger is not initialized"))?;
3467 let durable_tip = parse_verified_header(&position.finalized_tip)?;
3468 anyhow::ensure!(
3469 durable_tip.number >= checkpoint.number,
3470 "Durable finalized header tip precedes the trusted checkpoint"
3471 );
3472 let mut finalized_headers = vec![durable_tip];
3473 let durable_tip_verification = required_verification(
3474 self.verification.verify_block(durable_tip.number).await,
3475 "finality durable header tip",
3476 )?;
3477 anyhow::ensure!(
3478 durable_tip_verification.value == durable_tip,
3479 "Durable finalized header tip conflicts with independent sources"
3480 );
3481 decisions.push(verification_decision(
3482 &durable_tip_verification,
3483 Some(durable_tip.number),
3484 Some(durable_tip.number),
3485 ));
3486 anyhow::ensure!(
3487 finalized.number >= durable_tip.number,
3488 "Verified finalized height regressed below the durable finalized header tip"
3489 );
3490 let mut ancestry_cursor = durable_tip;
3491 while ancestry_cursor.number < finalized.number {
3492 let end = ancestry_cursor
3493 .number
3494 .saturating_add(4_096)
3495 .min(finalized.number);
3496 let start = ancestry_cursor.number.saturating_add(1);
3497 let ancestry_verification = required_verification(
3498 self.verification
3499 .verify_header_window(ancestry_cursor, end)
3500 .await,
3501 "finality ancestry",
3502 )?;
3503 let ancestry = &ancestry_verification.value;
3504 ancestry_cursor = *ancestry
3505 .last()
3506 .expect("nonempty finality ancestry advances the cursor");
3507 decisions.push(verification_decision(
3508 &ancestry_verification,
3509 Some(start),
3510 Some(end),
3511 ));
3512 finalized_headers.extend(ancestry.iter().copied());
3513 }
3514 anyhow::ensure!(
3515 ancestry_cursor == finalized,
3516 "Finalized header conflicts with its verified ancestry"
3517 );
3518
3519 let canonical_again_verification = required_verification(
3520 self.verification.verify_block(receipt.block_number).await,
3521 "finality inclusion header reread",
3522 )?;
3523 let canonical_again = canonical_again_verification.value;
3524 let finalized_again_verification = required_verification(
3525 self.verification.verify_block(finalized.number).await,
3526 "finalized header reread",
3527 )?;
3528 let finalized_again = finalized_again_verification.value;
3529 anyhow::ensure!(
3530 canonical_again.hash == receipt.block_hash && finalized_again == finalized,
3531 "Finality verification disagreed with the receipt or finalized header"
3532 );
3533 decisions.extend([
3534 verification_decision(
3535 &finalized_verification,
3536 Some(finalized.number),
3537 Some(finalized.number),
3538 ),
3539 verification_decision(
3540 &canonical_again_verification,
3541 Some(receipt.block_number),
3542 Some(receipt.block_number),
3543 ),
3544 verification_decision(
3545 &finalized_again_verification,
3546 Some(finalized.number),
3547 Some(finalized.number),
3548 ),
3549 ]);
3550 Ok(Some(StableFinality {
3551 decisions,
3552 inclusion_header: durable_verified_header(&canonical_again),
3553 finalized_headers: finalized_headers
3554 .iter()
3555 .map(durable_verified_header)
3556 .collect(),
3557 }))
3558 }
3559
3560 async fn commit_verified_finality(
3561 &self,
3562 included: &IncludedTransaction,
3563 status: TransactionStatus,
3564 verified_postconditions: &[ExecutionVerificationDecision],
3565 ) -> anyhow::Result<()> {
3566 anyhow::ensure!(
3567 matches!(
3568 status,
3569 TransactionStatus::Finalized | TransactionStatus::Reverted
3570 ) && included.receipt.status == (status == TransactionStatus::Finalized),
3571 "Finality status conflicts with the verified transaction receipt"
3572 );
3573 let mut decisions = included.finality.decisions.clone();
3574 decisions.extend_from_slice(verified_postconditions);
3575 let wallet_address = self.wallet_address.to_string();
3576 let transaction_hash = included.tx_hash.to_string();
3577 let block_hash = included.receipt.block_hash.to_string();
3578 let effective_gas_price = included.receipt.effective_gas_price.to_string();
3579 self.database
3580 .record_execution_finality_verified(&ExecutionFinalityTransition {
3581 intent_id: included.intent_id,
3582 chain_id: self.chain_id,
3583 wallet_address: &wallet_address,
3584 nonce: included.nonce,
3585 transaction_hash: &transaction_hash,
3586 status,
3587 block_number: included.block_number,
3588 block_hash: &block_hash,
3589 receipt_success: included.receipt.status,
3590 gas_used: included.receipt.gas_used,
3591 effective_gas_price: &effective_gas_price,
3592 manifest_version: &self.manifest_version,
3593 manifest_digest: &self.manifest_digest,
3594 provider_ids: &self.provider_ids,
3595 operator_ids: &self.operator_ids,
3596 failure_domain_ids: &self.failure_domain_ids,
3597 decisions: &decisions,
3598 finalized_headers: &included.finality.finalized_headers,
3599 })
3600 .await
3601 }
3602
3603 fn release_slot(&self) {
3604 *self.in_flight.lock() = None;
3605 }
3606}
3607
3608fn replacement_scan_range(from_block: u64, head_block: u64) -> anyhow::Result<RangeInclusive<u64>> {
3609 anyhow::ensure!(
3610 head_block >= from_block,
3611 "Canonical head {head_block} is behind execution creation block {from_block}"
3612 );
3613 let max_end = from_block.saturating_add(MAX_REPLACEMENT_SCAN_BLOCKS - 1);
3614 Ok(from_block..=head_block.min(max_end))
3615}
3616
3617fn current_unix_secs() -> anyhow::Result<u64> {
3618 SystemTime::now()
3619 .duration_since(UNIX_EPOCH)
3620 .map_err(|_| anyhow::anyhow!("Trusted host clock precedes the Unix epoch"))
3621 .map(|duration| duration.as_secs())
3622}
3623
3624fn validate_payload_operation_batch_size(batch_size: usize) -> anyhow::Result<i64> {
3625 anyhow::ensure!(
3626 (1..=MAX_PAYLOAD_OPERATION_BATCH_SIZE).contains(&batch_size),
3627 "Payload operation batch size must be between 1 and {MAX_PAYLOAD_OPERATION_BATCH_SIZE}"
3628 );
3629 Ok(i64::try_from(batch_size).expect("bounded payload batch size fits i64"))
3630}
3631
3632fn current_execution_hash(
3633 intent_id: i64,
3634 hashes: &[ExecutionTransactionHashRow],
3635) -> anyhow::Result<&ExecutionTransactionHashRow> {
3636 let mut current = hashes.iter().filter(|row| row.current);
3637 let row = current
3638 .next()
3639 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} has no current hash"))?;
3640 anyhow::ensure!(
3641 current.next().is_none(),
3642 "Execution intent {intent_id} has more than one current hash"
3643 );
3644 Ok(row)
3645}
3646
3647fn open_execution_payload(
3648 keys: &PayloadKeySet,
3649 policy: PayloadPolicy,
3650 intent: &ExecutionIntentRow,
3651 hash: &ExecutionTransactionHashRow,
3652 reason: &str,
3653) -> anyhow::Result<Vec<u8>> {
3654 anyhow::ensure!(
3655 hash.payload_expected,
3656 "Execution transaction {} has no signed payload",
3657 hash.transaction_hash
3658 );
3659 anyhow::ensure!(
3660 hash.raw_transaction.is_none(),
3661 "Protected execution transaction {} contains plaintext",
3662 hash.transaction_hash
3663 );
3664 let envelope = hash.sealed_transaction.as_deref().ok_or_else(|| {
3665 anyhow::anyhow!(
3666 "Protected execution transaction {} has no sealed payload",
3667 hash.transaction_hash
3668 )
3669 })?;
3670 let context = payload_context(intent, hash, keys.deployment_id())?;
3671 let raw_transaction = keys.unseal(envelope, &context)?;
3672 log::info!(
3673 "Unsealed execution payload for intent {} transaction {} during {reason}",
3674 intent.id,
3675 hash.transaction_hash
3676 );
3677 authenticate_retained_payload(&raw_transaction, intent, hash, keys.deployment_id())?;
3678 if retained_payload_requires_policy(intent, hash, policy)? {
3679 authenticate_payload(&raw_transaction, intent, hash, policy, keys.deployment_id())
3680 .with_context(|| {
3681 format!(
3682 "execution intent {} transaction {} violates current execution policy",
3683 intent.id, hash.transaction_hash
3684 )
3685 })?;
3686 }
3687 Ok(raw_transaction)
3688}
3689
3690fn receipt_max_polls(receipt_timeout_secs: u64) -> u32 {
3691 u32::try_from(receipt_timeout_secs.max(1)).unwrap_or(u32::MAX)
3692}
3693
3694fn receipt_timeout(receipt_timeout_secs: u64) -> Duration {
3695 Duration::from_secs(receipt_timeout_secs.clamp(1, u64::from(u32::MAX)))
3696}
3697
3698#[derive(Debug)]
3700struct SwapPlan {
3701 order: OrderAny,
3702 pool: Pool,
3703 quote_currency: Currency,
3704 instrument_id: InstrumentId,
3705 pool_address: Address,
3706 router: Address,
3707 factory: Address,
3708 weth: Address,
3709 token_in: Address,
3710 token_out: Address,
3711 fee: U24,
3712 amount_in: U256,
3713 min_amount_out: U256,
3714 slippage_bps: u32,
3715 quote_spend_ceiling: Option<QuoteSpendCeiling>,
3716 profiler_position: Option<BlockPosition>,
3717}
3718
3719#[derive(Debug, Clone)]
3720struct SwapQuoteAnchors {
3721 watermark: VerifiedBlockHeader,
3722 state: VerifiedBlockHeader,
3723 quote_contract: Address,
3724 token_in: Address,
3725 token_out: Address,
3726 fee: U24,
3727 quote_kind: SwapQuoteKind,
3728 quote: UniswapV3Quote,
3729 precondition_decisions: Vec<ExecutionVerificationDecision>,
3730}
3731
3732#[derive(Debug, Clone, Copy)]
3733enum SwapQuoteKind {
3734 ExactInput(U256),
3735 ExactOutput(U256),
3736}
3737
3738#[allow(
3748 clippy::large_futures,
3749 reason = "keeps transaction verification futures inline without additional boxing"
3750)]
3751async fn execute_swap(
3752 mut plan: SwapPlan,
3753 executor: TransactionExecutor,
3754 emitter: ExecutionEventEmitter,
3755 max_quote_age_blocks: u64,
3756 deadline_seconds: u64,
3757) -> anyhow::Result<()> {
3758 let order = &plan.order;
3759
3760 if let Err(e) = executor.claim_slot(TransactionPurpose::Swap) {
3761 emitter.emit_order_denied(order, &e.to_string());
3762 return Ok(());
3763 }
3764
3765 let Some(profiler_position) = plan.profiler_position.as_ref() else {
3766 release_preparing_slot(&executor.in_flight);
3767 emitter.emit_order_denied(order, "Pool profiler has no quote provenance");
3768 return Ok(());
3769 };
3770 let mut swap_anchors = match validate_swap_quote(
3771 profiler_position,
3772 &plan,
3773 max_quote_age_blocks,
3774 &executor.verification,
3775 &executor.deployment_manifest,
3776 )
3777 .await
3778 {
3779 Ok(anchors) => anchors,
3780 Err(e) => {
3781 release_preparing_slot(&executor.in_flight);
3782 emitter.emit_order_denied(order, &e.to_string());
3783 return Ok(());
3784 }
3785 };
3786 let (amount_in, min_amount_out) = match verified_swap_amounts(&plan, swap_anchors.quote) {
3787 Ok(amounts) => amounts,
3788 Err(e) => {
3789 release_preparing_slot(&executor.in_flight);
3790 emitter.emit_order_denied(order, &e.to_string());
3791 return Ok(());
3792 }
3793 };
3794 plan.amount_in = amount_in;
3795 plan.min_amount_out = min_amount_out;
3796 let deadline = match swap_anchors.state.timestamp.checked_add(deadline_seconds) {
3797 Some(deadline) => deadline,
3798 None => {
3799 release_preparing_slot(&executor.in_flight);
3800 emitter.emit_order_denied(
3801 order,
3802 &format!(
3803 "Swap deadline overflow: anchor timestamp {} plus `deadline_seconds` {deadline_seconds} exceeds u64",
3804 swap_anchors.state.timestamp
3805 ),
3806 );
3807 return Ok(());
3808 }
3809 };
3810
3811 swap_anchors.precondition_decisions =
3812 match check_swap_preconditions(&plan, swap_anchors.state.number, &executor).await {
3813 Ok(decisions) => decisions,
3814 Err(e) => {
3815 release_preparing_slot(&executor.in_flight);
3816 emitter.emit_order_denied(order, &e.to_string());
3817 return Ok(());
3818 }
3819 };
3820
3821 let calldata = UniswapV3SwapRouter::exactInputSingleCall {
3822 params: UniswapV3SwapRouter::ExactInputSingleParams {
3823 tokenIn: plan.token_in,
3824 tokenOut: plan.token_out,
3825 fee: plan.fee,
3826 recipient: executor.wallet_address,
3827 deadline: U256::from(deadline),
3828 amountIn: plan.amount_in,
3829 amountOutMinimum: plan.min_amount_out,
3830 sqrtPriceLimitX96: U160::ZERO,
3831 },
3832 }
3833 .abi_encode();
3834
3835 let calldata = Bytes::from(calldata);
3836 let intent = ExecutionIntentInsert {
3837 chain_id: executor.chain_id,
3838 wallet_address: executor.wallet_address.to_string(),
3839 purpose: TransactionPurpose::Swap.as_str().to_string(),
3840 client_order_id: Some(order.client_order_id().to_string()),
3841 trader_id: Some(order.trader_id().to_string()),
3842 strategy_id: Some(order.strategy_id().to_string()),
3843 account_id: Some(executor.account_id.to_string()),
3844 instrument_id: Some(plan.instrument_id.to_string()),
3845 pool_address: Some(plan.pool_address.to_string()),
3846 transaction_to: plan.router.to_string(),
3847 transaction_input: hex::encode_prefixed(&calldata),
3848 transaction_value: U256::ZERO.to_string(),
3849 amount_in: Some(plan.amount_in.to_string()),
3850 created_block: swap_anchors.state.number,
3851 };
3852 let intent = match executor.database.reserve_execution_intent(&intent).await {
3853 Ok(intent) => intent,
3854 Err(e) => {
3855 release_preparing_if_reservation_not_committed(&executor.in_flight, &e);
3856 emitter.emit_order_denied(order, &e.to_string());
3857 return Ok(());
3858 }
3859 };
3860 let prepared = match executor
3861 .prepare_and_sign_swap(
3862 intent.id,
3863 intent.created_block,
3864 plan.router,
3865 U256::ZERO,
3866 calldata,
3867 &swap_anchors,
3868 )
3869 .await
3870 {
3871 Ok(prepared) => prepared,
3872 Err(e) => {
3873 if executor
3874 .database
3875 .mark_execution_intent_recoverable(intent.id)
3876 .await
3877 .is_ok()
3878 {
3879 release_preparing_slot(&executor.in_flight);
3880 }
3881 emitter.emit_order_denied(order, &e.to_string());
3882 return Ok(());
3883 }
3884 };
3885
3886 if let Err(e) = executor
3887 .fill_and_persist(&prepared, TransactionPurpose::Swap)
3888 .await
3889 {
3890 emitter.emit_order_denied(order, &e.to_string());
3891 return Ok(());
3892 }
3893
3894 let broadcast = executor.broadcast(&prepared).await?;
3895 emitter.emit_order_submitted(order);
3896 executor
3897 .database
3898 .mark_execution_event_emitted(intent.id, "acknowledgement")
3899 .await?;
3900
3901 if let BroadcastOutcome::Ambiguous(message) = broadcast {
3902 log::warn!("{message}");
3903 }
3904
3905 let trace_purpose = match plan.order.order_side() {
3906 OrderSide::Sell => "swap_sell",
3907 OrderSide::Buy => "swap_buy",
3908 };
3909
3910 match executor.await_finality(&prepared).await? {
3911 InclusionOutcome::Finalized(mut included) => {
3912 included.finality.decisions.extend(
3913 verify_finalized_transaction(
3914 &included,
3915 &intent,
3916 prepared.nonce,
3917 &prepared.raw_tx,
3918 &executor,
3919 trace_purpose,
3920 )
3921 .await?,
3922 );
3923 let fill = validate_finalized_swap_fill(&plan, &included)?;
3924 let wallet = load_verified_wallet_after_fill(&plan, &included, &executor).await?;
3925 executor
3926 .commit_verified_finality(
3927 &included,
3928 TransactionStatus::Finalized,
3929 &wallet.decisions,
3930 )
3931 .await?;
3932 complete_finalized_swap(
3933 &plan,
3934 intent.id,
3935 included.tx_hash,
3936 fill,
3937 wallet,
3938 &executor,
3939 &emitter,
3940 )
3941 .await?;
3942 executor.release_slot();
3943 Ok(())
3944 }
3945 InclusionOutcome::Reverted(mut included) => {
3946 included.finality.decisions.extend(
3947 verify_finalized_transaction(
3948 &included,
3949 &intent,
3950 prepared.nonce,
3951 &prepared.raw_tx,
3952 &executor,
3953 trace_purpose,
3954 )
3955 .await?,
3956 );
3957 executor
3958 .commit_verified_finality(&included, TransactionStatus::Reverted, &[])
3959 .await?;
3960 send_reverted_order(&emitter, order, &included)?;
3961 executor
3962 .database
3963 .mark_execution_event_emitted(intent.id, "terminal")
3964 .await?;
3965 executor.release_slot();
3966 Ok(())
3967 }
3968 InclusionOutcome::Pending(message) => anyhow::bail!(message),
3969 }
3970}
3971
3972async fn validate_swap_quote(
3973 position: &BlockPosition,
3974 plan: &SwapPlan,
3975 max_age_blocks: u64,
3976 verification: &VerificationCoordinator,
3977 manifest: &BlockchainDeploymentManifest,
3978) -> anyhow::Result<SwapQuoteAnchors> {
3979 let block_hash = position.block_hash.as_deref().ok_or_else(|| {
3980 anyhow::anyhow!(
3981 "Pool state at block {} has no ingestion-time block hash; refresh the profiler before execution",
3982 position.number
3983 )
3984 })?;
3985 let expected_block_hash = B256::from_str(block_hash)
3986 .with_context(|| format!("Invalid profiler block hash {block_hash}"))?;
3987 let now_unix_secs = current_unix_secs()?;
3988 let head = verified_value(
3989 verification.verify_decision_header(now_unix_secs).await,
3990 "swap decision header",
3991 )?;
3992 validate_quote_age(position.number, head.number, max_age_blocks)?;
3993 let canonical_block = verified_value(
3994 verification.verify_block(position.number).await,
3995 "profiler watermark header",
3996 )?;
3997 anyhow::ensure!(
3998 canonical_block.hash == expected_block_hash,
3999 "Pool state block {} changed from {} to {}; refresh the profiler before execution",
4000 position.number,
4001 expected_block_hash,
4002 canonical_block.hash
4003 );
4004
4005 let snapshot_transaction = position.transaction_index == BLOCK_SCOPED_SNAPSHOT_INDEX;
4006 let snapshot_log = position.log_index == BLOCK_SCOPED_SNAPSHOT_INDEX;
4007 anyhow::ensure!(
4008 snapshot_transaction == snapshot_log,
4009 "Pool state at block {} has an invalid partial snapshot watermark",
4010 position.number
4011 );
4012
4013 if snapshot_transaction {
4014 let snapshot_hash = B256::from_str(&position.transaction_hash)
4015 .with_context(|| "Invalid block-scoped snapshot hash")?;
4016 anyhow::ensure!(
4017 snapshot_hash == expected_block_hash,
4018 "Block-scoped snapshot hash {snapshot_hash} does not match ingestion hash {expected_block_hash}"
4019 );
4020 } else {
4021 validate_profiler_event_verified(
4022 position,
4023 expected_block_hash,
4024 plan.pool_address,
4025 &plan.pool,
4026 verification,
4027 )
4028 .await?;
4029 }
4030
4031 let ancestry = verified_value(
4032 verification
4033 .verify_header_window(canonical_block, head.number)
4034 .await,
4035 "profiler-to-decision ancestry",
4036 )?;
4037
4038 if let Some(last) = ancestry.last() {
4039 anyhow::ensure!(
4040 *last == head,
4041 "Swap decision header conflicts with profiler ancestry"
4042 );
4043 } else {
4044 anyhow::ensure!(
4045 canonical_block == head,
4046 "Empty profiler ancestry does not end at the decision header"
4047 );
4048 }
4049 verified_value(
4050 verification
4051 .verify_deployment_manifest(manifest, head.number)
4052 .await,
4053 "swap deployment manifest",
4054 )?;
4055 let quote_contract = validate_manifest_pool(plan, manifest)?;
4056 let quote_kind = match plan.order.order_side() {
4057 OrderSide::Sell => SwapQuoteKind::ExactInput(quantity_to_raw_amount(
4058 plan.order.quantity(),
4059 plan.pool.get_base_token().decimals,
4060 )?),
4061 OrderSide::Buy => SwapQuoteKind::ExactOutput(quantity_to_raw_amount(
4062 plan.order.quantity(),
4063 plan.pool.get_base_token().decimals,
4064 )?),
4065 };
4066 let quote = verified_value(
4067 verify_swap_quote(verification, quote_contract, plan, quote_kind, head.number).await,
4068 "independent swap quote",
4069 )?;
4070
4071 Ok(SwapQuoteAnchors {
4072 watermark: canonical_block,
4073 state: head,
4074 quote_contract,
4075 token_in: plan.token_in,
4076 token_out: plan.token_out,
4077 fee: plan.fee,
4078 quote_kind,
4079 quote,
4080 precondition_decisions: Vec::new(),
4081 })
4082}
4083
4084async fn verify_swap_quote(
4085 verification: &VerificationCoordinator,
4086 quote_contract: Address,
4087 plan: &SwapPlan,
4088 kind: SwapQuoteKind,
4089 block: u64,
4090) -> VerificationOutcome<UniswapV3Quote> {
4091 match kind {
4092 SwapQuoteKind::ExactInput(amount_in) => {
4093 verification
4094 .verify_quote_exact_input_single(
4095 "e_contract,
4096 plan.token_in,
4097 plan.token_out,
4098 amount_in,
4099 plan.fee,
4100 block,
4101 )
4102 .await
4103 }
4104 SwapQuoteKind::ExactOutput(amount_out) => {
4105 verification
4106 .verify_quote_exact_output_single(
4107 "e_contract,
4108 plan.token_in,
4109 plan.token_out,
4110 amount_out,
4111 plan.fee,
4112 block,
4113 )
4114 .await
4115 }
4116 }
4117}
4118
4119fn verified_swap_amounts(plan: &SwapPlan, quote: UniswapV3Quote) -> anyhow::Result<(U256, U256)> {
4120 anyhow::ensure!(
4121 !quote.amount.is_zero(),
4122 "Independent swap quote returned zero"
4123 );
4124 let base_amount =
4125 quantity_to_raw_amount(plan.order.quantity(), plan.pool.get_base_token().decimals)?;
4126 let slippage_bps = plan.slippage_bps;
4127 match plan.order.order_side() {
4128 OrderSide::Sell => Ok((
4129 base_amount,
4130 derive_min_amount_out(quote.amount, slippage_bps)?,
4131 )),
4132 OrderSide::Buy => {
4133 let ceiling = plan.quote_spend_ceiling.ok_or_else(|| {
4134 anyhow::anyhow!(
4135 "No quote spend ceiling for BUY token pair {} -> {}",
4136 plan.token_in,
4137 plan.token_out
4138 )
4139 })?;
4140 anyhow::ensure!(
4141 quote.amount <= ceiling.max_amount,
4142 "BUY quote amount {} exceeds the configured quote-spend maximum {} for {} -> {}",
4143 quote.amount,
4144 ceiling.max_amount,
4145 plan.token_in,
4146 plan.token_out
4147 );
4148 Ok((
4149 quote.amount,
4150 derive_min_amount_out(base_amount, slippage_bps)?,
4151 ))
4152 }
4153 }
4154}
4155
4156fn validate_manifest_pool(
4157 plan: &SwapPlan,
4158 manifest: &BlockchainDeploymentManifest,
4159) -> anyhow::Result<Address> {
4160 let matching = manifest
4161 .pools
4162 .iter()
4163 .filter(|pool| Address::from_str(&pool.address).ok() == Some(plan.pool_address))
4164 .collect::<Vec<_>>();
4165 anyhow::ensure!(
4166 matching.len() == 1,
4167 "Pool {} does not have exactly one deployment manifest definition",
4168 plan.pool_address
4169 );
4170 let pool = matching[0];
4171 let token0 = Address::from_str(&pool.token0)?;
4172 let token1 = Address::from_str(&pool.token1)?;
4173 let factory = Address::from_str(&pool.factory)?;
4174 let quote_contract = Address::from_str(&pool.quote_contract)?;
4175 anyhow::ensure!(
4176 token0 == plan.pool.token0.address
4177 && token1 == plan.pool.token1.address
4178 && pool.fee == plan.pool.fee.expect("validated pool fee")
4179 && factory == plan.factory,
4180 "Cached pool {} does not match its deployment manifest identity",
4181 plan.pool_address
4182 );
4183
4184 for token in [&plan.pool.token0, &plan.pool.token1] {
4185 let identities = manifest
4186 .tokens
4187 .iter()
4188 .filter(|identity| Address::from_str(&identity.address).ok() == Some(token.address))
4189 .collect::<Vec<_>>();
4190 anyhow::ensure!(
4191 identities.len() == 1,
4192 "Token {} does not have exactly one deployment manifest identity",
4193 token.address
4194 );
4195 let identity = identities[0];
4196 anyhow::ensure!(
4197 identity.name == token.name
4198 && identity.symbol == token.symbol
4199 && identity.decimals == token.decimals,
4200 "Cached token {} does not match its deployment manifest identity",
4201 token.address
4202 );
4203 let expected_role = if token.address == plan.pool.get_base_token().address {
4204 "base"
4205 } else {
4206 "quote"
4207 };
4208 anyhow::ensure!(
4209 matches!(identity.asset_role.as_str(), "both") || identity.asset_role == expected_role,
4210 "Token {} is not permitted as the pool {expected_role} asset",
4211 token.address
4212 );
4213 }
4214 Ok(quote_contract)
4215}
4216
4217async fn validate_profiler_event_verified(
4218 position: &BlockPosition,
4219 expected_block_hash: B256,
4220 pool_address: Address,
4221 pool: &Pool,
4222 verification: &VerificationCoordinator,
4223) -> anyhow::Result<()> {
4224 let transaction_hash = B256::from_str(&position.transaction_hash).with_context(|| {
4225 format!(
4226 "Invalid profiler transaction hash {}",
4227 position.transaction_hash
4228 )
4229 })?;
4230 let receipt = verified_value(
4231 verification.verify_receipt(&transaction_hash).await,
4232 "profiler watermark receipt",
4233 )?;
4234 anyhow::ensure!(
4235 receipt.status,
4236 "Profiler transaction did not execute successfully"
4237 );
4238 anyhow::ensure!(
4239 receipt.transaction_hash == transaction_hash,
4240 "Profiler receipt transaction hash does not match its ingestion watermark"
4241 );
4242 anyhow::ensure!(
4243 receipt.block_number == position.number
4244 && receipt.block_hash == expected_block_hash
4245 && receipt.transaction_index == u64::from(position.transaction_index),
4246 "Profiler receipt position does not match its ingestion watermark"
4247 );
4248 let matching_logs = receipt
4249 .logs
4250 .iter()
4251 .filter(|log| rpc_log::extract_log_index(log).ok() == Some(position.log_index))
4252 .collect::<Vec<_>>();
4253 anyhow::ensure!(
4254 matching_logs.len() == 1,
4255 "Profiler receipt contains {} logs at global index {}; expected exactly one",
4256 matching_logs.len(),
4257 position.log_index
4258 );
4259 let log = matching_logs[0];
4260 let log_transaction_hash = B256::from_str(&rpc_log::extract_transaction_hash(log)?)
4261 .with_context(|| "Invalid profiler log transaction hash")?;
4262 let log_block_hash = log
4263 .block_hash
4264 .as_deref()
4265 .ok_or_else(|| anyhow::anyhow!("Profiler log has no block hash"))?;
4266 anyhow::ensure!(
4267 !log.removed
4268 && log_transaction_hash == transaction_hash
4269 && rpc_log::extract_block_number(log)? == position.number
4270 && rpc_log::extract_transaction_index(log)? == position.transaction_index
4271 && B256::from_str(log_block_hash)? == expected_block_hash,
4272 "Profiler log position does not match its ingestion watermark"
4273 );
4274 anyhow::ensure!(
4275 rpc_log::extract_address(log)? == pool_address,
4276 "Profiler watermark log did not come from expected pool {pool_address}"
4277 );
4278 let signature = log
4279 .topics
4280 .first()
4281 .ok_or_else(|| anyhow::anyhow!("Profiler watermark log has no event signature"))?;
4282 let supported =
4283 profiler_event_signatures(pool).any(|expected| expected.eq_ignore_ascii_case(signature));
4284 anyhow::ensure!(
4285 supported,
4286 "Profiler watermark log has an unsupported event signature"
4287 );
4288 Ok(())
4289}
4290
4291fn profiler_event_signatures(pool: &Pool) -> impl Iterator<Item = &str> {
4292 [
4293 Some(pool.dex.swap_created_event.as_ref()),
4294 Some(pool.dex.mint_created_event.as_ref()),
4295 Some(pool.dex.burn_created_event.as_ref()),
4296 Some(pool.dex.collect_created_event.as_ref()),
4297 pool.dex.flash_created_event.as_deref(),
4298 pool.dex.fee_protocol_update_event.as_deref(),
4299 pool.dex.fee_protocol_collect_event.as_deref(),
4300 ]
4301 .into_iter()
4302 .flatten()
4303}
4304
4305fn validate_quote_age(
4306 profiler_block: u64,
4307 latest_block: u64,
4308 max_age_blocks: u64,
4309) -> anyhow::Result<()> {
4310 anyhow::ensure!(
4311 profiler_block <= latest_block,
4312 "Pool state at block {profiler_block} is ahead of the latest block {latest_block}; the execution RPC endpoint lags the data feed"
4313 );
4314 let quote_age = latest_block - profiler_block;
4315 anyhow::ensure!(
4316 quote_age <= max_age_blocks,
4317 "Stale quote: pool state at block {profiler_block}, latest block {latest_block}, exceeds `max_quote_age_blocks` {max_age_blocks}"
4318 );
4319 Ok(())
4320}
4321
4322fn validate_rpc_transaction_matches_payload(
4323 transaction: &RpcTransaction,
4324 raw_transaction: &[u8],
4325) -> anyhow::Result<()> {
4326 let signed = decode_signed_transaction(raw_transaction)?;
4327 anyhow::ensure!(
4328 transaction.hash == signed.hash
4329 && transaction.from == signed.signer
4330 && transaction.nonce == signed.nonce
4331 && transaction.chain_id == Some(signed.chain_id)
4332 && transaction.transaction_type == Some(2)
4333 && transaction.to == Some(signed.to)
4334 && transaction.input == signed.input
4335 && transaction.value == signed.value
4336 && transaction.gas == Some(signed.gas_limit)
4337 && transaction.max_fee_per_gas == Some(U256::from(signed.max_fee_per_gas))
4338 && transaction.max_priority_fee_per_gas
4339 == Some(U256::from(signed.max_priority_fee_per_gas)),
4340 "Verified transaction fields differ from the authenticated signed payload"
4341 );
4342 Ok(())
4343}
4344
4345#[allow(
4346 clippy::large_futures,
4347 reason = "keeps transaction verification futures inline without additional boxing"
4348)]
4349async fn verify_finalized_transaction(
4350 included: &IncludedTransaction,
4351 intent: &ExecutionIntentRow,
4352 nonce: u64,
4353 raw_transaction: &[u8],
4354 executor: &TransactionExecutor,
4355 trace_purpose: &str,
4356) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4357 verify_finalized_transaction_identity(
4358 included,
4359 intent,
4360 nonce,
4361 raw_transaction,
4362 &executor.verification,
4363 executor.wallet_address,
4364 executor.chain_id,
4365 &executor.deployment_manifest,
4366 trace_purpose,
4367 )
4368 .await
4369}
4370
4371#[expect(clippy::too_many_arguments)]
4372async fn verify_finalized_transaction_identity(
4373 included: &IncludedTransaction,
4374 intent: &ExecutionIntentRow,
4375 nonce: u64,
4376 raw_transaction: &[u8],
4377 verification: &VerificationCoordinator,
4378 wallet_address: Address,
4379 chain_id: u32,
4380 deployment_manifest: &BlockchainDeploymentManifest,
4381 trace_purpose: &str,
4382) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4383 let transaction_verification = required_verification(
4384 verification.verify_transaction(&included.tx_hash).await,
4385 "finalized transaction",
4386 )?;
4387 let transaction = &transaction_verification.value;
4388 let signed = decode_signed_transaction(raw_transaction)?;
4389 let (expected_to, expected_input, expected_value) = persisted_call_fields(intent)?;
4390 anyhow::ensure!(
4391 included.receipt.transaction_hash == included.tx_hash
4392 && signed.hash == included.tx_hash
4393 && signed.signer == wallet_address
4394 && signed.chain_id == u64::from(chain_id)
4395 && signed.nonce == nonce
4396 && signed.to == expected_to
4397 && signed.input == expected_input
4398 && signed.value == expected_value,
4399 "Finalized transaction does not match the authenticated signed payload and persisted intent"
4400 );
4401 validate_rpc_transaction_matches_payload(transaction, raw_transaction)
4402 .context("finalized transaction identity mismatch")?;
4403
4404 let trace_verification = required_verification(
4405 verification.verify_call_trace(&included.tx_hash).await,
4406 "finalized call trace",
4407 )?;
4408 validate_call_trace(
4409 &trace_verification.value,
4410 &signed,
4411 included.receipt.status,
4412 trace_purpose,
4413 deployment_manifest,
4414 )?;
4415 let deployment_verification = required_verification(
4416 verification
4417 .verify_deployment_manifest(deployment_manifest, included.block_number)
4418 .await,
4419 "inclusion deployment manifest",
4420 )?;
4421
4422 Ok(vec![
4423 verification_decision(
4424 &transaction_verification,
4425 Some(included.block_number),
4426 Some(included.block_number),
4427 ),
4428 verification_decision(
4429 &trace_verification,
4430 Some(included.block_number),
4431 Some(included.block_number),
4432 ),
4433 verification_decision(
4434 &deployment_verification,
4435 Some(included.block_number),
4436 Some(included.block_number),
4437 ),
4438 ])
4439}
4440
4441fn validate_call_trace(
4442 trace: &VerifiedCallTrace,
4443 signed: &crate::execution::transaction::DecodedSignedTransaction,
4444 receipt_success: bool,
4445 purpose: &str,
4446 manifest: &BlockchainDeploymentManifest,
4447) -> anyhow::Result<()> {
4448 anyhow::ensure!(
4449 trace.call_type == RpcCallType::Call
4450 && trace.from == signed.signer
4451 && trace.to == Some(signed.to)
4452 && trace.value == signed.value
4453 && trace.input_digest == keccak256(&signed.input)
4454 && trace.success == receipt_success,
4455 "Verified call-trace root differs from the authenticated transaction"
4456 );
4457 validate_internal_calls(&trace.calls, signed.to, purpose, manifest)
4458}
4459
4460fn validate_internal_calls(
4461 calls: &[VerifiedCallTrace],
4462 caller_context: Address,
4463 purpose: &str,
4464 manifest: &BlockchainDeploymentManifest,
4465) -> anyhow::Result<()> {
4466 for call in calls {
4467 anyhow::ensure!(
4468 call.from == caller_context,
4469 "Verified call trace child has an invalid caller context"
4470 );
4471 let target = call.to.ok_or_else(|| {
4472 anyhow::anyhow!("Verified call trace contains an operation without a target")
4473 })?;
4474 let call_type = match call.call_type {
4475 RpcCallType::Call => "call",
4476 RpcCallType::Callcode => "callcode",
4477 RpcCallType::Delegatecall => "delegatecall",
4478 RpcCallType::Staticcall => "staticcall",
4479 RpcCallType::Create | RpcCallType::Create2 | RpcCallType::Selfdestruct => {
4480 anyhow::bail!("Verified call trace contains a forbidden state-changing operation")
4481 }
4482 };
4483 let permitted = manifest.call_edges.iter().any(|edge| {
4484 edge.purpose == purpose
4485 && edge.call_type.eq_ignore_ascii_case(call_type)
4486 && Address::from_str(&edge.caller).ok() == Some(call.from)
4487 && Address::from_str(&edge.target).ok() == Some(target)
4488 });
4489 anyhow::ensure!(
4490 permitted,
4491 "Verified call trace contains an unreviewed {call_type} edge {} -> {target} for {purpose}",
4492 call.from
4493 );
4494 let child_context = match call.call_type {
4495 RpcCallType::Call | RpcCallType::Staticcall => target,
4496 RpcCallType::Callcode | RpcCallType::Delegatecall => caller_context,
4497 RpcCallType::Create | RpcCallType::Create2 | RpcCallType::Selfdestruct => {
4498 unreachable!("forbidden operations return before child traversal")
4499 }
4500 };
4501 validate_internal_calls(&call.calls, child_context, purpose, manifest)?;
4502 }
4503 Ok(())
4504}
4505
4506async fn verify_wrap_balance_increase(
4507 executor: &TransactionExecutor,
4508 weth_address: &Address,
4509 amount_wei: U256,
4510 included: &IncludedTransaction,
4511) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4512 let previous_block = included.block_number.checked_sub(1).ok_or_else(|| {
4513 anyhow::anyhow!(
4514 "Included wrap transaction {} has invalid block number 0",
4515 included.tx_hash
4516 )
4517 })?;
4518 let call = ERC20::balanceOfCall {
4519 account: executor.wallet_address,
4520 }
4521 .abi_encode();
4522 let balance_before = required_verification(
4523 executor
4524 .verification
4525 .verify_decoded_call(
4526 None,
4527 weth_address,
4528 U256::ZERO,
4529 &call,
4530 previous_block,
4531 |result| ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into),
4532 )
4533 .await,
4534 "wrapped balance before finality",
4535 )
4536 .with_context(|| {
4537 format!(
4538 "failed to verify WETH balance before included transaction {} at block {previous_block}",
4539 included.tx_hash
4540 )
4541 })?;
4542 let balance_after = required_verification(
4543 executor
4544 .verification
4545 .verify_decoded_call(
4546 None,
4547 weth_address,
4548 U256::ZERO,
4549 &call,
4550 included.block_number,
4551 |result| ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into),
4552 )
4553 .await,
4554 "wrapped balance after finality",
4555 )
4556 .with_context(|| {
4557 format!(
4558 "failed to verify WETH balance after included transaction {} at block {}",
4559 included.tx_hash, included.block_number
4560 )
4561 })?;
4562 let expected_balance = balance_before
4563 .value
4564 .checked_add(amount_wei)
4565 .ok_or_else(|| {
4566 anyhow::anyhow!(
4567 "WETH balance overflow for included transaction {} at block {}",
4568 included.tx_hash,
4569 included.block_number
4570 )
4571 })?;
4572 anyhow::ensure!(
4573 balance_after.value == expected_balance,
4574 "WETH balance after transaction {} did not increase by {amount_wei}: expected {expected_balance}, was {}",
4575 included.tx_hash,
4576 balance_after.value
4577 );
4578
4579 Ok(vec![
4580 verification_decision(&balance_before, Some(previous_block), Some(previous_block)),
4581 verification_decision(
4582 &balance_after,
4583 Some(included.block_number),
4584 Some(included.block_number),
4585 ),
4586 ])
4587}
4588
4589async fn verify_approve_allowance(
4590 executor: &TransactionExecutor,
4591 token: &Address,
4592 router: &Address,
4593 amount: U256,
4594 included: &IncludedTransaction,
4595) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4596 let call = ERC20::allowanceCall {
4597 owner: executor.wallet_address,
4598 spender: *router,
4599 }
4600 .abi_encode();
4601 let allowance = required_verification(
4602 executor
4603 .verification
4604 .verify_decoded_call(
4605 None,
4606 token,
4607 U256::ZERO,
4608 &call,
4609 included.block_number,
4610 |result| ERC20::allowanceCall::abi_decode_returns(result).map_err(Into::into),
4611 )
4612 .await,
4613 "router allowance after finality",
4614 )
4615 .with_context(|| {
4616 format!(
4617 "failed to verify router allowance after included transaction {} at block {}",
4618 included.tx_hash, included.block_number
4619 )
4620 })?;
4621 anyhow::ensure!(
4622 allowance.value == amount,
4623 "Router allowance after transaction {} does not equal the requested amount {amount}: was {}",
4624 included.tx_hash,
4625 allowance.value
4626 );
4627
4628 Ok(vec![verification_decision(
4629 &allowance,
4630 Some(included.block_number),
4631 Some(included.block_number),
4632 )])
4633}
4634
4635async fn complete_finalized_swap(
4636 plan: &SwapPlan,
4637 intent_id: i64,
4638 tx_hash: B256,
4639 fill: Option<FinalizedSwapFill>,
4640 wallet: VerifiedWalletRefresh,
4641 executor: &TransactionExecutor,
4642 emitter: &ExecutionEventEmitter,
4643) -> anyhow::Result<()> {
4644 if let Some(fill) = fill {
4645 let filled = OrderFilled::new(
4646 emitter.trader_id(),
4647 plan.order.strategy_id(),
4648 plan.order.instrument_id(),
4649 plan.order.client_order_id(),
4650 fill.venue_order_id,
4651 emitter.account_id(),
4652 fill.trade_id,
4653 plan.order.order_side(),
4654 plan.order.order_type(),
4655 fill.last_qty,
4656 fill.last_px,
4657 plan.quote_currency,
4658 LiquiditySide::Taker,
4659 execution_event_id(tx_hash, b"fill"),
4660 fill.ts_event,
4661 fill.ts_event,
4662 false,
4663 None,
4664 Some(fill.commission),
4665 None,
4666 );
4667 emitter.try_send_order_event(OrderEventAny::Filled(filled))?;
4668
4669 if fill.last_qty < plan.order.quantity() {
4670 let canceled = OrderCanceled::new(
4671 emitter.trader_id(),
4672 plan.order.strategy_id(),
4673 plan.order.instrument_id(),
4674 plan.order.client_order_id(),
4675 execution_event_id(tx_hash, b"partial_cancel"),
4676 fill.ts_event,
4677 fill.ts_event,
4678 false,
4679 Some(fill.venue_order_id),
4680 Some(emitter.account_id()),
4681 None,
4682 );
4683 emitter.try_send_order_event(OrderEventAny::Canceled(canceled))?;
4684 }
4685 }
4686
4687 *executor.wallet_balance.lock() = wallet.wallet_balance;
4688 emitter.try_emit_account_state(
4689 wallet.balances,
4690 vec![],
4691 true,
4692 get_atomic_clock_realtime().get_time_ns(),
4693 None,
4694 )?;
4695 executor
4696 .database
4697 .mark_execution_event_emitted(intent_id, "fill")
4698 .await
4699}
4700
4701fn send_reverted_order(
4702 emitter: &ExecutionEventEmitter,
4703 order: &OrderAny,
4704 included: &IncludedTransaction,
4705) -> anyhow::Result<()> {
4706 let ts_event = finalized_inclusion_time(included)?;
4707 let rejected = OrderRejected::new(
4708 emitter.trader_id(),
4709 order.strategy_id(),
4710 order.instrument_id(),
4711 order.client_order_id(),
4712 emitter.account_id(),
4713 format!("Transaction {} reverted on-chain", included.tx_hash).into(),
4714 execution_event_id(included.tx_hash, b"reverted"),
4715 ts_event,
4716 ts_event,
4717 false,
4718 false,
4719 );
4720 emitter.try_send_order_event(OrderEventAny::Rejected(rejected))
4721}
4722
4723fn finalized_inclusion_time(included: &IncludedTransaction) -> anyhow::Result<UnixNanos> {
4724 let inclusion = &included.finality.inclusion_header;
4725 anyhow::ensure!(
4726 inclusion.number == included.block_number
4727 && inclusion.hash == included.receipt.block_hash.to_string(),
4728 "Verified inclusion header does not match the finalized receipt"
4729 );
4730 let timestamp = inclusion.timestamp;
4731 let nanos = timestamp
4732 .checked_mul(NANOSECONDS_IN_SECOND)
4733 .ok_or_else(|| anyhow::anyhow!("Verified inclusion timestamp exceeds nanoseconds"))?;
4734 Ok(UnixNanos::from(nanos))
4735}
4736
4737fn execution_event_id(tx_hash: B256, event: &[u8]) -> UUID4 {
4738 let mut identity = Vec::with_capacity(tx_hash.len() + event.len());
4739 identity.extend_from_slice(tx_hash.as_slice());
4740 identity.extend_from_slice(event);
4741 let digest = keccak256(identity);
4742 let mut bytes = [0u8; 16];
4743 bytes.copy_from_slice(&digest[..16]);
4744 UUID4::from_bytes(bytes)
4745}
4746
4747struct FinalizedSwapFill {
4748 venue_order_id: VenueOrderId,
4749 trade_id: TradeId,
4750 last_qty: Quantity,
4751 last_px: Price,
4752 commission: Money,
4753 ts_event: UnixNanos,
4754}
4755
4756struct VerifiedWalletRefresh {
4757 wallet_balance: WalletBalance,
4758 balances: Vec<AccountBalance>,
4759 decisions: Vec<ExecutionVerificationDecision>,
4760}
4761
4762fn validate_finalized_swap_fill(
4763 plan: &SwapPlan,
4764 included: &IncludedTransaction,
4765) -> anyhow::Result<Option<FinalizedSwapFill>> {
4766 let signature =
4767 keccak256("Swap(address,address,int256,int256,uint160,uint128,int24)").to_string();
4768 let swap_logs = included
4769 .receipt
4770 .logs
4771 .iter()
4772 .filter(|log| {
4773 !log.removed
4774 && log.topics.first().is_some_and(|topic| topic == &signature)
4775 && Address::from_str(&log.address).ok() == Some(plan.pool_address)
4776 })
4777 .collect::<Vec<_>>();
4778 anyhow::ensure!(
4779 swap_logs.len() == 1,
4780 "Finalized transaction {} emitted {} Swap logs from expected pool {}; expected exactly one",
4781 included.tx_hash,
4782 swap_logs.len(),
4783 plan.pool_address
4784 );
4785 let log = swap_logs[0];
4786 let log_transaction_hash = B256::from_str(&rpc_log::extract_transaction_hash(log)?)
4787 .with_context(|| "Invalid finalized Swap log transaction hash")?;
4788 let log_block_hash = log
4789 .block_hash
4790 .as_deref()
4791 .ok_or_else(|| anyhow::anyhow!("Finalized Swap log has no block hash"))?;
4792 anyhow::ensure!(
4793 log_transaction_hash == included.tx_hash
4794 && rpc_log::extract_block_number(log)? == included.block_number
4795 && u64::from(rpc_log::extract_transaction_index(log)?)
4796 == included.receipt.transaction_index
4797 && B256::from_str(log_block_hash)
4798 .with_context(|| "Invalid finalized Swap log block hash")?
4799 == included.receipt.block_hash,
4800 "Finalized Swap log position does not match transaction {}",
4801 included.tx_hash
4802 );
4803
4804 let dex = crate::exchanges::get_dex_extended(plan.pool.chain.name, &plan.pool.dex.name)
4805 .ok_or_else(|| {
4806 anyhow::anyhow!(
4807 "No RPC Swap decoder for {}:{}",
4808 plan.pool.chain.name,
4809 plan.pool.dex.name
4810 )
4811 })?;
4812 let event = dex.parse_swap_event_rpc(log)?;
4813 let (base_amount, quote_amount) =
4814 if plan.pool.get_base_token().address == plan.pool.token0.address {
4815 (event.amount0, event.amount1)
4816 } else {
4817 (event.amount1, event.amount0)
4818 };
4819 let last_qty = match plan.order.order_side() {
4820 OrderSide::Sell => {
4821 anyhow::ensure!(
4822 base_amount.is_positive() && base_amount.unsigned_abs() == plan.amount_in,
4823 "Finalized Swap input {base_amount} does not match the persisted amount {}",
4824 plan.amount_in
4825 );
4826 plan.order.quantity()
4827 }
4828 OrderSide::Buy => {
4829 anyhow::ensure!(
4830 quote_amount.is_positive() && quote_amount.unsigned_abs() == plan.amount_in,
4831 "Finalized Swap input {quote_amount} does not match the persisted amount {}",
4832 plan.amount_in
4833 );
4834 anyhow::ensure!(
4835 base_amount.is_negative(),
4836 "Finalized Swap base amount {base_amount} is not a BUY output"
4837 );
4838 raw_amount_to_quantity(
4839 base_amount.unsigned_abs(),
4840 plan.pool.get_base_token().decimals,
4841 )?
4842 }
4843 };
4844
4845 let block = &included.finality.inclusion_header;
4846 anyhow::ensure!(
4847 block.number == included.block_number
4848 && block.hash == included.receipt.block_hash.to_string(),
4849 "Verified inclusion header {} does not match receipt hash {}",
4850 included.block_number,
4851 included.receipt.block_hash
4852 );
4853 let timestamp_ns = block
4854 .timestamp
4855 .checked_mul(NANOSECONDS_IN_SECOND)
4856 .ok_or_else(|| anyhow::anyhow!("Finalized block timestamp overflows nanoseconds"))?;
4857 let mut swap = event.to_pool_swap(
4858 Arc::clone(&plan.pool.chain),
4859 plan.instrument_id,
4860 plan.pool.pool_identifier,
4861 UnixNanos::from(timestamp_ns),
4862 );
4863 swap.calculate_trade_info(&plan.pool.token0, &plan.pool.token1, None)?;
4864 let trade = swap
4865 .trade_info
4866 .as_ref()
4867 .ok_or_else(|| anyhow::anyhow!("Finalized Swap has no calculated trade information"))?;
4868 anyhow::ensure!(
4869 trade.order_side == plan.order.order_side(),
4870 "Finalized Swap side {} does not match {} order",
4871 trade.order_side,
4872 plan.order.order_side()
4873 );
4874 let gas_cost = included
4875 .receipt
4876 .effective_gas_price
4877 .checked_mul(U256::from(included.receipt.gas_used))
4878 .ok_or_else(|| anyhow::anyhow!("Finalized transaction gas commission overflow"))?;
4879 let commission = Money::from_u256(gas_cost, plan.pool.chain.native_currency())?;
4880 let trade_digest = keccak256(format!("{}:{}", included.tx_hash, swap.log_index));
4881 let trade_digest = trade_digest.to_string();
4882 let trade_id = TradeId::new_checked(&trade_digest[2..38])?;
4883
4884 if plan
4885 .order
4886 .trade_ids()
4887 .iter()
4888 .any(|existing| **existing == trade_id)
4889 {
4890 return Ok(None);
4891 }
4892
4893 let venue_order_id = VenueOrderId::new_checked(included.tx_hash.to_string())?;
4894 let ts_event = UnixNanos::from(timestamp_ns);
4895 let last_px = match plan.order.order_side() {
4896 OrderSide::Buy => fill_price_from_quote(last_qty, plan.amount_in, plan.quote_currency)?,
4897 _ => trade.execution_price,
4898 };
4899 Ok(Some(FinalizedSwapFill {
4900 venue_order_id,
4901 trade_id,
4902 last_qty,
4903 last_px,
4904 commission,
4905 ts_event,
4906 }))
4907}
4908
4909async fn load_verified_wallet_after_fill(
4910 plan: &SwapPlan,
4911 included: &IncludedTransaction,
4912 executor: &TransactionExecutor,
4913) -> anyhow::Result<VerifiedWalletRefresh> {
4914 let mut token_universe = executor.wallet_balance.lock().token_universe.clone();
4915 token_universe.insert(plan.pool.token0.address);
4916 token_universe.insert(plan.pool.token1.address);
4917
4918 let native_amount = required_verification(
4919 executor
4920 .verification
4921 .verify_balance(&executor.wallet_address, included.block_number)
4922 .await,
4923 "finalized native balance",
4924 )?;
4925 let native_balance = Money::from_u256(native_amount.value, plan.pool.chain.native_currency())?;
4926 let mut decisions = vec![verification_decision(
4927 &native_amount,
4928 Some(included.block_number),
4929 Some(included.block_number),
4930 )];
4931 let mut token_addresses = token_universe.iter().copied().collect::<Vec<_>>();
4932 token_addresses.sort_unstable();
4933 let mut token_balances = Vec::with_capacity(token_addresses.len());
4934 for address in token_addresses {
4935 let identities = executor
4936 .deployment_manifest
4937 .tokens
4938 .iter()
4939 .filter(|identity| Address::from_str(&identity.address).ok() == Some(address))
4940 .collect::<Vec<_>>();
4941 anyhow::ensure!(
4942 identities.len() == 1,
4943 "Wallet token {address} does not have exactly one deployment manifest identity"
4944 );
4945 let identity = identities[0];
4946 let token = Token::new(
4947 Arc::clone(&plan.pool.chain),
4948 address,
4949 identity.name.clone(),
4950 identity.symbol.clone(),
4951 identity.decimals,
4952 );
4953 let call = ERC20::balanceOfCall {
4954 account: executor.wallet_address,
4955 }
4956 .abi_encode();
4957 let amount = required_verification(
4958 executor
4959 .verification
4960 .verify_decoded_call(
4961 None,
4962 &address,
4963 U256::ZERO,
4964 &call,
4965 included.block_number,
4966 |result| ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into),
4967 )
4968 .await,
4969 "finalized token balance",
4970 )?;
4971 decisions.push(verification_decision(
4972 &amount,
4973 Some(included.block_number),
4974 Some(included.block_number),
4975 ));
4976 token_balances.push(TokenBalance::new(amount.value, token));
4977 }
4978
4979 let mut wallet_balance = WalletBalance::new(token_universe);
4980 let balances = wallet_balance.replace_balances(native_balance, token_balances)?;
4981 Ok(VerifiedWalletRefresh {
4982 wallet_balance,
4983 balances,
4984 decisions,
4985 })
4986}
4987
4988async fn verify_connect_capabilities(
4989 verification: &VerificationCoordinator,
4990 manifest: &BlockchainDeploymentManifest,
4991 wallet: Address,
4992 weth: Address,
4993 block: u64,
4994) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4995 let contract = manifest
4996 .contracts
4997 .first()
4998 .ok_or_else(|| anyhow::anyhow!("Deployment manifest has no capability probe target"))?;
4999 let contract_address = Address::from_str(&contract.address)
5000 .with_context(|| "Deployment manifest capability probe target is invalid")?;
5001 let storage = required_verification(
5002 verification
5003 .verify_storage(&contract_address, &B256::ZERO, block)
5004 .await,
5005 "Blockchain explicit-height storage capability",
5006 )?;
5007
5008 let balance_call = ERC20::balanceOfCall { account: wallet }.abi_encode();
5009 let gas = required_verification(
5010 verification
5011 .verify_gas_estimate(&wallet, &weth, U256::ZERO, &balance_call, block)
5012 .await,
5013 "Blockchain explicit-height gas capability",
5014 )?;
5015
5016 let mut decisions = vec![
5017 verification_decision(&storage, Some(block), Some(block)),
5018 verification_decision(&gas, Some(block), Some(block)),
5019 ];
5020
5021 for pool in &manifest.pools {
5022 let quote_contract = Address::from_str(&pool.quote_contract)
5023 .with_context(|| "Deployment manifest quote capability target is invalid")?;
5024 let token_in = Address::from_str(&pool.token0)
5025 .with_context(|| "Deployment manifest quote input token is invalid")?;
5026 let token_out = Address::from_str(&pool.token1)
5027 .with_context(|| "Deployment manifest quote output token is invalid")?;
5028 let fee = U24::try_from(pool.fee)
5029 .map_err(|_| anyhow::anyhow!("Deployment manifest pool fee is invalid"))?;
5030 let quote = required_verification(
5031 verification
5032 .verify_quote_exact_input_single(
5033 "e_contract,
5034 token_in,
5035 token_out,
5036 U256::from(1u64),
5037 fee,
5038 block,
5039 )
5040 .await,
5041 "Blockchain explicit-height quote capability",
5042 )?;
5043 decisions.push(verification_decision("e, Some(block), Some(block)));
5044 }
5045
5046 let trace = required_verification(
5047 verification.verify_call_trace_capability().await,
5048 "Blockchain call trace capability",
5049 )?;
5050 decisions.push(verification_decision(&trace, None, None));
5051 Ok(decisions)
5052}
5053
5054async fn check_swap_preconditions(
5059 plan: &SwapPlan,
5060 block: u64,
5061 executor: &TransactionExecutor,
5062) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
5063 let mut decisions = Vec::new();
5064 let factory_call = UniswapV3RouterState::factoryCall.abi_encode();
5065 let router_factory = required_verification(
5066 executor
5067 .verification
5068 .verify_decoded_call(
5069 None,
5070 &plan.router,
5071 U256::ZERO,
5072 &factory_call,
5073 block,
5074 |result| {
5075 UniswapV3RouterState::factoryCall::abi_decode_returns(result)
5076 .map_err(Into::into)
5077 },
5078 )
5079 .await,
5080 "swap router factory",
5081 )?;
5082 anyhow::ensure!(
5083 router_factory.value == plan.factory,
5084 "Router {} reports an unexpected factory",
5085 plan.router,
5086 );
5087 decisions.push(verification_decision(
5088 &router_factory,
5089 Some(block),
5090 Some(block),
5091 ));
5092
5093 let weth_call = UniswapV3RouterState::WETH9Call.abi_encode();
5094 let router_weth = required_verification(
5095 executor
5096 .verification
5097 .verify_decoded_call(
5098 None,
5099 &plan.router,
5100 U256::ZERO,
5101 &weth_call,
5102 block,
5103 |result| {
5104 UniswapV3RouterState::WETH9Call::abi_decode_returns(result).map_err(Into::into)
5105 },
5106 )
5107 .await,
5108 "swap router wrapped native",
5109 )?;
5110 anyhow::ensure!(
5111 router_weth.value == plan.weth,
5112 "Router {} reports an unexpected wrapped native contract",
5113 plan.router,
5114 );
5115 decisions.push(verification_decision(
5116 &router_weth,
5117 Some(block),
5118 Some(block),
5119 ));
5120
5121 let pool_call = UniswapV3Factory::getPoolCall {
5122 tokenA: plan.token_in,
5123 tokenB: plan.token_out,
5124 fee: plan.fee,
5125 }
5126 .abi_encode();
5127 let registered_pool = required_verification(
5128 executor
5129 .verification
5130 .verify_decoded_call(
5131 None,
5132 &plan.factory,
5133 U256::ZERO,
5134 &pool_call,
5135 block,
5136 |result| {
5137 UniswapV3Factory::getPoolCall::abi_decode_returns(result).map_err(Into::into)
5138 },
5139 )
5140 .await,
5141 "swap factory pool",
5142 )?;
5143 anyhow::ensure!(
5144 registered_pool.value == plan.pool_address,
5145 "Factory resolves an unexpected pool for the swap token pair and fee"
5146 );
5147 decisions.push(verification_decision(
5148 ®istered_pool,
5149 Some(block),
5150 Some(block),
5151 ));
5152
5153 for token in [&plan.pool.token0, &plan.pool.token1] {
5154 let decimals_call = ERC20::decimalsCall.abi_encode();
5155 let decimals = required_verification(
5156 executor
5157 .verification
5158 .verify_decoded_call(
5159 None,
5160 &token.address,
5161 U256::ZERO,
5162 &decimals_call,
5163 block,
5164 |result| ERC20::decimalsCall::abi_decode_returns(result).map_err(Into::into),
5165 )
5166 .await,
5167 "swap token decimals",
5168 )?;
5169 anyhow::ensure!(
5170 decimals.value == token.decimals,
5171 "Token {} reports unexpected decimals",
5172 token.address,
5173 );
5174 decisions.push(verification_decision(&decimals, Some(block), Some(block)));
5175 }
5176
5177 let allowance_call = ERC20::allowanceCall {
5178 owner: executor.wallet_address,
5179 spender: plan.router,
5180 }
5181 .abi_encode();
5182 let allowance = required_verification(
5183 executor
5184 .verification
5185 .verify_decoded_call(
5186 None,
5187 &plan.token_in,
5188 U256::ZERO,
5189 &allowance_call,
5190 block,
5191 |result| ERC20::allowanceCall::abi_decode_returns(result).map_err(Into::into),
5192 )
5193 .await,
5194 "swap input allowance",
5195 )?;
5196
5197 if allowance.value < plan.amount_in {
5198 anyhow::bail!(
5199 "Router allowance {} is below the swap amount {} for input token {}; approve the router explicitly before submitting",
5200 allowance.value,
5201 plan.amount_in,
5202 plan.token_in
5203 );
5204 }
5205 decisions.push(verification_decision(&allowance, Some(block), Some(block)));
5206
5207 let balance_call = ERC20::balanceOfCall {
5208 account: executor.wallet_address,
5209 }
5210 .abi_encode();
5211 let balance = required_verification(
5212 executor
5213 .verification
5214 .verify_decoded_call(
5215 None,
5216 &plan.token_in,
5217 U256::ZERO,
5218 &balance_call,
5219 block,
5220 |result| ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into),
5221 )
5222 .await,
5223 "swap input balance",
5224 )?;
5225
5226 if balance.value < plan.amount_in {
5227 anyhow::bail!(
5228 "Input token {} balance {} is below the swap amount {}",
5229 plan.token_in,
5230 balance.value,
5231 plan.amount_in
5232 );
5233 }
5234 decisions.push(verification_decision(&balance, Some(block), Some(block)));
5235
5236 Ok(decisions)
5237}
5238
5239fn quantity_to_raw_amount(quantity: Quantity, decimals: u8) -> anyhow::Result<U256> {
5246 if quantity.is_zero() {
5247 anyhow::bail!("Order quantity must be positive");
5248 }
5249
5250 let raw = U256::from(quantity.raw());
5251 let raw_precision = quantity.precision.max(FIXED_PRECISION);
5252 if decimals >= raw_precision {
5253 let scale = U256::from(10u64)
5254 .checked_pow(U256::from(decimals - raw_precision))
5255 .ok_or_else(|| anyhow::anyhow!("Order amount scaling overflow"))?;
5256 raw.checked_mul(scale).ok_or_else(|| {
5257 anyhow::anyhow!("Order amount overflow scaling quantity to raw token units")
5258 })
5259 } else {
5260 let divisor = U256::from(10u64)
5261 .checked_pow(U256::from(raw_precision - decimals))
5262 .ok_or_else(|| anyhow::anyhow!("Order amount scaling overflow"))?;
5263 if !(raw % divisor).is_zero() {
5264 anyhow::bail!(
5265 "Order quantity {quantity} is not exactly representable in {decimals} base token decimals"
5266 );
5267 }
5268 Ok(raw / divisor)
5269 }
5270}
5271
5272fn swap_token_pair(side: OrderSide, base: Address, quote: Address) -> (Address, Address) {
5273 match side {
5274 OrderSide::Sell => (base, quote),
5275 OrderSide::Buy => (quote, base),
5276 }
5277}
5278
5279fn fill_price_from_quote(
5280 last_qty: Quantity,
5281 quote_amount: U256,
5282 quote_currency: Currency,
5283) -> anyhow::Result<Price> {
5284 let quote = Money::from_u256(quote_amount, quote_currency)?;
5285 Price::from_decimal_dp(quote.as_decimal() / last_qty.as_decimal(), FIXED_PRECISION)
5286 .map_err(anyhow::Error::from)
5287}
5288
5289fn raw_amount_to_quantity(amount: U256, decimals: u8) -> anyhow::Result<Quantity> {
5290 if amount.is_zero() {
5291 anyhow::bail!("Executed amount must be positive");
5292 }
5293 let quantity = if decimals >= FIXED_PRECISION {
5294 let scale = U256::from(10u64)
5295 .checked_pow(U256::from(decimals - FIXED_PRECISION))
5296 .ok_or_else(|| anyhow::anyhow!("Executed amount scaling overflow"))?;
5297 Quantity::from_u256(amount / scale, FIXED_PRECISION).map_err(anyhow::Error::from)?
5298 } else {
5299 Quantity::from_u256(amount, decimals).map_err(anyhow::Error::from)?
5300 };
5301
5302 if quantity.is_zero() {
5303 anyhow::bail!(
5304 "Executed amount {amount} is below representable quantity precision {FIXED_PRECISION}"
5305 );
5306 }
5307 Ok(quantity)
5308}
5309
5310fn exact_output_amount(quote: &SwapQuote, zero_for_one: bool) -> anyhow::Result<U256> {
5311 let amount = if zero_for_one {
5312 quote.amount1
5313 } else {
5314 quote.amount0
5315 };
5316
5317 if !amount.is_negative() {
5318 anyhow::bail!("Swap quote output amount {amount} is not a positive output");
5319 }
5320 Ok(amount.unsigned_abs())
5321}
5322
5323fn derive_min_amount_out(quoted_amount_out: U256, slippage_bps: u32) -> anyhow::Result<U256> {
5327 if slippage_bps >= BPS_DENOMINATOR {
5328 anyhow::bail!("Slippage {slippage_bps} bps must be below {BPS_DENOMINATOR}");
5329 }
5330 let min_amount_out = quoted_amount_out
5331 .checked_mul(U256::from(BPS_DENOMINATOR - slippage_bps))
5332 .and_then(|scaled| scaled.checked_div(U256::from(BPS_DENOMINATOR)))
5333 .ok_or_else(|| anyhow::anyhow!("Minimum output derivation overflow"))?;
5334 if min_amount_out.is_zero() {
5335 anyhow::bail!(
5336 "Derived minimum output is zero for quoted output {quoted_amount_out} at {slippage_bps} bps slippage"
5337 );
5338 }
5339 Ok(min_amount_out)
5340}
5341
5342impl BlockchainExecutionClient {
5343 #[allow(
5344 clippy::large_futures,
5345 reason = "keeps transaction verification futures inline without additional boxing"
5346 )]
5347 async fn build_execution_verification_migration(
5348 &self,
5349 snapshot: ExecutionVerificationMigrationSnapshot,
5350 finalized: VerifiedBlockHeader,
5351 finalized_headers: &[VerifiedBlockHeader],
5352 nonce_verification: &Verified<u64>,
5353 ) -> anyhow::Result<ExecutionVerificationMigration> {
5354 let next_canonical_nonce = nonce_verification.value;
5355 let mut hashes_by_intent: HashMap<i64, Vec<&ExecutionTransactionHashRow>> = HashMap::new();
5356 for hash in &snapshot.hashes {
5357 hashes_by_intent
5358 .entry(hash.intent_id)
5359 .or_default()
5360 .push(hash);
5361 }
5362 let active_count = snapshot
5363 .intents
5364 .iter()
5365 .filter(|intent| intent.active)
5366 .count();
5367 anyhow::ensure!(
5368 active_count <= 1,
5369 "Retained execution history has multiple active signer owners"
5370 );
5371
5372 let mut nonce_owners = HashMap::new();
5373 let mut records = Vec::with_capacity(snapshot.intents.len());
5374 let finalized_headers = finalized_headers
5375 .iter()
5376 .map(durable_verified_header)
5377 .collect::<Vec<_>>();
5378
5379 for intent in &snapshot.intents {
5380 anyhow::ensure!(
5381 intent.chain_id == self.chain.chain_id
5382 && intent.wallet_address == self.config.wallet_address,
5383 "Retained execution intent belongs to another signer"
5384 );
5385 let purpose = TransactionPurpose::parse(&intent.purpose).ok_or_else(|| {
5386 anyhow::anyhow!("Retained execution intent has an unsupported purpose")
5387 })?;
5388 let hashes = hashes_by_intent
5389 .get(&intent.id)
5390 .map(Vec::as_slice)
5391 .unwrap_or_default();
5392 let current = hashes
5393 .iter()
5394 .copied()
5395 .filter(|hash| hash.current)
5396 .collect::<Vec<_>>();
5397 anyhow::ensure!(
5398 current.len() <= 1,
5399 "Retained execution intent {} has multiple current hashes",
5400 intent.id
5401 );
5402 let current = current.first().copied();
5403 let mut authenticated = HashMap::new();
5404
5405 for hash in hashes {
5406 if hash.payload_expected {
5407 let raw = open_execution_payload(
5408 self.payload_keys
5409 .as_deref()
5410 .expect("Postgres execution requires payload keys"),
5411 self.payload_policy(),
5412 intent,
5413 hash,
5414 "verification migration",
5415 )?;
5416 authenticated.insert(hash.id, raw);
5417 } else {
5418 anyhow::ensure!(
5419 hash.raw_transaction.is_none() && hash.sealed_transaction.is_none(),
5420 "Unowned replacement hash retains signed bytes"
5421 );
5422 }
5423 }
5424
5425 if let Some(nonce) = intent.nonce {
5426 anyhow::ensure!(
5427 nonce_owners.insert(nonce, intent.id).is_none(),
5428 "Retained execution history has duplicate signer nonce ownership"
5429 );
5430 }
5431
5432 let base_decision = verification_decision(
5433 nonce_verification,
5434 Some(finalized.number),
5435 Some(finalized.number),
5436 );
5437
5438 if !intent.active {
5439 if matches!(intent.status.as_str(), "finalized" | "reverted") {
5440 let expected_marker =
5441 if purpose == TransactionPurpose::Swap && intent.status == "finalized" {
5442 intent.fill_emitted
5443 } else {
5444 intent.terminal_emitted
5445 };
5446 anyhow::ensure!(
5447 expected_marker,
5448 "Released terminal intent {} has no durable event marker",
5449 intent.id
5450 );
5451 } else {
5452 anyhow::ensure!(
5453 matches!(intent.status.as_str(), "dropped" | "recoverable")
5454 && authenticated.is_empty(),
5455 "Released nonterminal intent {} retains signed ownership",
5456 intent.id
5457 );
5458 records.push(ExecutionVerificationMigrationRecord {
5459 intent_id: intent.id,
5460 nonce: intent.nonce,
5461 transaction_hash: None,
5462 terminal_status: None,
5463 block_number: None,
5464 block_hash: None,
5465 receipt_success: None,
5466 gas_used: None,
5467 effective_gas_price: None,
5468 recover_prepared: false,
5469 decisions: vec![base_decision],
5470 });
5471 continue;
5472 }
5473 }
5474
5475 if intent.active && intent.nonce.is_none() {
5476 anyhow::ensure!(
5477 intent.status == "prepared" && hashes.is_empty(),
5478 "Unassigned active intent {} is not an unsigned preparation",
5479 intent.id
5480 );
5481 records.push(ExecutionVerificationMigrationRecord {
5482 intent_id: intent.id,
5483 nonce: None,
5484 transaction_hash: None,
5485 terminal_status: None,
5486 block_number: None,
5487 block_hash: None,
5488 receipt_success: None,
5489 gas_used: None,
5490 effective_gas_price: None,
5491 recover_prepared: true,
5492 decisions: vec![base_decision],
5493 });
5494 continue;
5495 }
5496
5497 let nonce = intent.nonce.ok_or_else(|| {
5498 anyhow::anyhow!("Retained signed intent {} has no nonce", intent.id)
5499 })?;
5500 let current = current.ok_or_else(|| {
5501 anyhow::anyhow!("Retained signed intent {} has no current hash", intent.id)
5502 })?;
5503 let raw_transaction = authenticated.get(¤t.id).ok_or_else(|| {
5504 anyhow::anyhow!(
5505 "Retained signed intent {} has no authenticated current payload",
5506 intent.id
5507 )
5508 })?;
5509
5510 if intent.active && nonce == next_canonical_nonce {
5511 anyhow::ensure!(
5512 !matches!(intent.status.as_str(), "finalized" | "reverted"),
5513 "Active terminal intent conflicts with the canonical nonce ledger"
5514 );
5515 records.push(ExecutionVerificationMigrationRecord {
5516 intent_id: intent.id,
5517 nonce: Some(nonce),
5518 transaction_hash: Some(current.transaction_hash.clone()),
5519 terminal_status: None,
5520 block_number: None,
5521 block_hash: None,
5522 receipt_success: None,
5523 gas_used: None,
5524 effective_gas_price: None,
5525 recover_prepared: false,
5526 decisions: vec![base_decision],
5527 });
5528 continue;
5529 }
5530 anyhow::ensure!(
5531 nonce < next_canonical_nonce,
5532 "Retained active nonce {nonce} is above canonical nonce {next_canonical_nonce}"
5533 );
5534
5535 let receipt_verification = required_verification(
5536 self.verification
5537 .verify_receipt(&B256::from_str(¤t.transaction_hash).with_context(
5538 || {
5539 format!(
5540 "Retained transaction hash {} is invalid",
5541 current.transaction_hash
5542 )
5543 },
5544 )?)
5545 .await,
5546 "migration receipt",
5547 )?;
5548 let receipt = receipt_verification.value.clone();
5549 anyhow::ensure!(
5550 receipt.block_number <= finalized.number,
5551 "Retained terminal receipt is above the verified finalized boundary"
5552 );
5553 let inclusion_verification = required_verification(
5554 self.verification.verify_block(receipt.block_number).await,
5555 "migration inclusion header",
5556 )?;
5557 anyhow::ensure!(
5558 inclusion_verification.value.hash == receipt.block_hash
5559 && finalized_headers.iter().any(|header| {
5560 header.number == receipt.block_number
5561 && header.hash == receipt.block_hash.to_string()
5562 }),
5563 "Retained terminal receipt is not on the verified finalized ancestry"
5564 );
5565 let tx_hash = B256::from_str(¤t.transaction_hash)
5566 .context("Retained transaction hash is invalid")?;
5567 let included = IncludedTransaction {
5568 intent_id: intent.id,
5569 nonce,
5570 tx_hash,
5571 block_number: receipt.block_number,
5572 receipt: receipt.clone(),
5573 finality: StableFinality {
5574 decisions: Vec::new(),
5575 inclusion_header: durable_verified_header(&inclusion_verification.value),
5576 finalized_headers: finalized_headers.clone(),
5577 },
5578 };
5579 let trace_purpose = match purpose {
5580 TransactionPurpose::Wrap => "wrap",
5581 TransactionPurpose::Approve => "approve",
5582 TransactionPurpose::Swap => {
5583 match self.restore_swap_plan(intent)?.order.order_side() {
5584 OrderSide::Sell => "swap_sell",
5585 OrderSide::Buy => "swap_buy",
5586 }
5587 }
5588 };
5589 let mut decisions = vec![
5590 verification_decision(
5591 &receipt_verification,
5592 Some(receipt.block_number),
5593 Some(receipt.block_number),
5594 ),
5595 verification_decision(
5596 &inclusion_verification,
5597 Some(receipt.block_number),
5598 Some(receipt.block_number),
5599 ),
5600 ];
5601 decisions.extend(
5602 verify_finalized_transaction_identity(
5603 &included,
5604 intent,
5605 nonce,
5606 raw_transaction,
5607 &self.verification,
5608 self.wallet_address,
5609 self.chain.chain_id,
5610 &self
5611 .config
5612 .verification
5613 .as_ref()
5614 .expect("verification config validated")
5615 .deployment_manifest,
5616 trace_purpose,
5617 )
5618 .await?,
5619 );
5620 let terminal_status = if receipt.status {
5621 TransactionStatus::Finalized
5622 } else {
5623 TransactionStatus::Reverted
5624 };
5625
5626 if !intent.active {
5627 anyhow::ensure!(
5628 intent.status == terminal_status.as_str(),
5629 "Released terminal intent status conflicts with its verified receipt"
5630 );
5631 }
5632 records.push(ExecutionVerificationMigrationRecord {
5633 intent_id: intent.id,
5634 nonce: Some(nonce),
5635 transaction_hash: Some(current.transaction_hash.clone()),
5636 terminal_status: Some(terminal_status),
5637 block_number: Some(receipt.block_number),
5638 block_hash: Some(receipt.block_hash.to_string()),
5639 receipt_success: Some(receipt.status),
5640 gas_used: Some(receipt.gas_used),
5641 effective_gas_price: Some(receipt.effective_gas_price.to_string()),
5642 recover_prepared: false,
5643 decisions,
5644 });
5645 }
5646
5647 Ok(ExecutionVerificationMigration { snapshot, records })
5648 }
5649}
5650
5651#[async_trait(?Send)]
5652impl ExecutionClient for BlockchainExecutionClient {
5653 fn is_connected(&self) -> bool {
5654 self.core.is_connected()
5655 }
5656
5657 fn client_id(&self) -> ClientId {
5658 self.core.client_id
5659 }
5660
5661 fn account_id(&self) -> AccountId {
5662 self.core.account_id
5663 }
5664
5665 fn venue(&self) -> Venue {
5666 self.core.venue
5667 }
5668
5669 fn handles_order_venue(&self, venue: Venue) -> bool {
5670 venue.parse_dex().is_ok_and(|(blockchain, dex_type)| {
5671 blockchain == self.chain.name && dex_type == DexType::UniswapV3
5672 })
5673 }
5674
5675 fn oms_type(&self) -> OmsType {
5676 self.core.oms_type
5677 }
5678
5679 fn get_account(&self) -> Option<AccountAny> {
5680 self.core.cache().account_owned(&self.core.account_id)
5681 }
5682
5683 fn generate_account_state(
5684 &self,
5685 balances: Vec<AccountBalance>,
5686 margins: Vec<MarginBalance>,
5687 reported: bool,
5688 ts_event: UnixNanos,
5689 info: Option<Params>,
5690 ) -> anyhow::Result<()> {
5691 self.emitter
5692 .try_emit_account_state(balances, margins, reported, ts_event, info)
5693 }
5694
5695 fn start(&mut self) -> anyhow::Result<()> {
5696 if self.core.is_started() {
5697 return Ok(());
5698 }
5699
5700 self.emitter.set_sender(get_exec_event_sender());
5701 self.core.set_started();
5702 log::info!(
5703 "Started: client_id={}, account_id={}",
5704 self.core.client_id,
5705 self.core.account_id
5706 );
5707 Ok(())
5708 }
5709
5710 fn stop(&mut self) -> anyhow::Result<()> {
5711 if self.core.is_stopped() {
5712 return Ok(());
5713 }
5714
5715 self.pending_tasks.begin_shutdown();
5716 self.signer = None;
5717 self.core.set_stopped();
5718 self.core.set_disconnected();
5719 log::info!("Stopped: client_id={}", self.core.client_id);
5720 Ok(())
5721 }
5722
5723 #[allow(
5724 clippy::large_futures,
5725 reason = "keeps transaction verification futures inline without additional boxing"
5726 )]
5727 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
5728 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
5729
5730 if order.is_closed() {
5731 log::warn!("Cannot submit closed order {}", order.client_order_id());
5732 return Ok(());
5733 }
5734
5735 if let Err(reason) = validate_order(&order) {
5736 self.emitter.emit_order_denied(&order, &reason.to_string());
5737 return Ok(());
5738 }
5739
5740 if !self.pending_tasks.is_open() {
5741 self.emitter
5742 .emit_order_denied(&order, "Blockchain execution client is shutting down");
5743 return Ok(());
5744 }
5745
5746 let plan = match self.prepare_swap(&cmd, &order) {
5747 Ok(plan) => plan,
5748 Err(e) => {
5749 self.emitter.emit_order_denied(&order, &e.to_string());
5750 return Ok(());
5751 }
5752 };
5753
5754 let executor = match self.transaction_executor() {
5755 Ok(executor) => executor,
5756 Err(e) => {
5757 self.emitter.emit_order_denied(&order, &e.to_string());
5758 return Ok(());
5759 }
5760 };
5761
5762 let emitter = self.emitter.clone();
5763 let max_quote_age_blocks = self.transaction_limits.max_quote_age_blocks;
5764 let deadline_seconds = self.transaction_limits.deadline_seconds;
5765 let client_order_id = order.client_order_id();
5766
5767 let future = async move {
5768 if let Err(e) = execute_swap(
5769 plan,
5770 executor,
5771 emitter,
5772 max_quote_age_blocks,
5773 deadline_seconds,
5774 )
5775 .await
5776 {
5777 log::warn!("Swap execution for order {client_order_id} failed: {e:?}");
5778 }
5779 };
5780
5781 if let Err(e) = self.pending_tasks.spawn(future) {
5782 release_preparing_slot(&self.in_flight);
5783 log::warn!("Skipping blockchain swap after shutdown began: {e}");
5784 }
5785
5786 Ok(())
5787 }
5788
5789 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
5790 let orders = self
5791 .core
5792 .cache()
5793 .orders_for_ids(&cmd.order_list.client_order_ids, &cmd);
5794
5795 for order in &orders {
5796 if order.is_closed() {
5797 log::warn!("Cannot submit closed order {}", order.client_order_id());
5798 continue;
5799 }
5800
5801 self.emitter
5802 .emit_order_denied(order, ORDER_LIST_UNSUPPORTED);
5803 }
5804
5805 Ok(())
5806 }
5807
5808 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
5809 let Ok(order) = self.core.cache().try_order_owned(&cmd.client_order_id) else {
5810 log::warn!("Cannot modify unknown order {}", cmd.client_order_id);
5811 return Ok(());
5812 };
5813
5814 self.emitter.emit_order_modify_rejected(
5815 &order,
5816 cmd.venue_order_id,
5817 ORDER_MODIFY_UNSUPPORTED,
5818 get_atomic_clock_realtime().get_time_ns(),
5819 );
5820 Ok(())
5821 }
5822
5823 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
5824 let Ok(order) = self.core.cache().try_order_owned(&cmd.client_order_id) else {
5825 log::warn!("Cannot cancel unknown order {}", cmd.client_order_id);
5826 return Ok(());
5827 };
5828
5829 self.emitter.emit_order_cancel_rejected(
5830 &order,
5831 cmd.venue_order_id,
5832 ORDER_CANCEL_UNSUPPORTED,
5833 get_atomic_clock_realtime().get_time_ns(),
5834 );
5835 Ok(())
5836 }
5837
5838 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
5839 log::warn!(
5840 "Cancel-all for {} is not supported on the blockchain execution client",
5841 cmd.instrument_id
5842 );
5843 Ok(())
5844 }
5845
5846 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
5847 for cancel in cmd.cancels {
5848 self.cancel_order(cancel)?;
5849 }
5850 Ok(())
5851 }
5852
5853 fn query_account(&self, cmd: QueryAccount) -> anyhow::Result<()> {
5854 anyhow::ensure!(
5855 cmd.account_id == self.core.account_id,
5856 "Query account ID {} does not match client account ID {}",
5857 cmd.account_id,
5858 self.core.account_id
5859 );
5860 anyhow::ensure!(self.core.is_started(), "Execution client is not started");
5861
5862 let balances = self.wallet_balance.lock().as_account_balances()?;
5863 self.generate_account_state(
5864 balances,
5865 vec![],
5866 true,
5867 get_atomic_clock_realtime().get_time_ns(),
5868 None,
5869 )
5870 }
5871
5872 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
5873 log::debug!(
5874 "Order queries are not supported on the blockchain execution client; cannot query {}",
5875 cmd.client_order_id
5876 );
5877 Ok(())
5878 }
5879
5880 fn retain_unresolved_submissions(&self) -> bool {
5881 true
5882 }
5883
5884 #[allow(
5885 clippy::large_futures,
5886 reason = "keeps transaction verification futures inline without additional boxing"
5887 )]
5888 async fn connect(&mut self) -> anyhow::Result<()> {
5889 if self.core.is_connected() {
5890 log::warn!("Blockchain execution client already connected");
5891 return Ok(());
5892 }
5893
5894 log::info!(
5895 "Connecting to blockchain execution client on chain {}",
5896 self.chain.name
5897 );
5898
5899 if !self.pending_tasks.is_open() || !self.pending_tasks.is_empty() {
5900 self.pending_tasks.begin_shutdown();
5901 self.pending_tasks
5902 .finish_shutdown(Duration::from_secs(5), Duration::from_secs(2))
5903 .await
5904 .map_err(|e| anyhow::anyhow!("Failed to terminate blockchain submissions: {e}"))?;
5905 self.signer = None;
5906 self.pending_tasks
5907 .start_generation()
5908 .map_err(|e| anyhow::anyhow!("Failed to start blockchain task generation: {e}"))?;
5909 }
5910 release_preparing_slot(&self.in_flight);
5911
5912 let setup_guard = TaskGroupGuard::new(&[&self.pending_tasks], || {});
5913
5914 let payload_keys = PayloadKeySet::load(
5915 self.config.payload_key_env.as_deref(),
5916 &self.config.payload_key_retired_env,
5917 self.config.payload_deployment_id.as_deref(),
5918 )?
5919 .map(Arc::new);
5920
5921 if self.cache.database.is_some() || self.config.postgres_cache_database_config.is_some() {
5922 let keys = payload_keys.as_deref().ok_or_else(|| {
5923 anyhow::anyhow!(
5924 "Postgres execution requires an active payload key and deployment identity"
5925 )
5926 })?;
5927
5928 if self.cache.database.is_none() {
5929 let pg_options = self
5930 .config
5931 .postgres_cache_database_config
5932 .as_ref()
5933 .expect("Postgres configuration checked above");
5934 let database = crate::cache::database::BlockchainCacheDatabase::connect(
5935 pg_options.clone().into(),
5936 )
5937 .await
5938 .map_err(|e| {
5939 anyhow::anyhow!("Failed to connect to the Postgres cache database: {e}")
5940 })?;
5941 self.cache.database = Some(database);
5942 }
5943 self.cache
5944 .database
5945 .as_ref()
5946 .expect("database was attached")
5947 .require_execution_payload_storage_ready(keys)
5948 .await?;
5949 self.cache.initialize_chain().await;
5950 self.cache.ensure_execution_transaction_schema().await?;
5951 let check = self
5952 .cache
5953 .database
5954 .as_ref()
5955 .expect("database was attached")
5956 .check_execution_payload_storage(
5957 Some(keys),
5958 Some(PayloadPolicy {
5959 chain_id: self.chain.chain_id,
5960 signer: self.wallet_address,
5961 gas_limit: self.config.gas_limit,
5962 max_fee_per_gas: self.config.max_fee_per_gas_wei,
5963 }),
5964 100,
5965 )
5966 .await?;
5967 anyhow::ensure!(
5968 check.protected,
5969 "Postgres execution requires protected payload storage"
5970 );
5971 } else {
5972 log::warn!(
5973 "No Postgres cache database configured; transactions will be refused (no durable store)"
5974 );
5975 }
5976 self.payload_keys = payload_keys;
5977
5978 let verification = self
5979 .config
5980 .verification
5981 .as_ref()
5982 .expect("verification config validated at construction");
5983 let position = if let Some(database) = self.cache.database.as_ref() {
5984 database
5985 .load_execution_verification_position(
5986 self.chain.chain_id,
5987 &self.config.wallet_address,
5988 &verification.manifest_version,
5989 &verification.manifest_digest,
5990 )
5991 .await?
5992 } else {
5993 None
5994 };
5995 let migration_snapshot = if position.is_none() {
5996 if let Some(database) = self.cache.database.as_ref() {
5997 let snapshot = database
5998 .load_execution_verification_migration_snapshot(
5999 self.chain.chain_id,
6000 &self.config.wallet_address,
6001 )
6002 .await?;
6003
6004 if snapshot.intents.is_empty() {
6005 None
6006 } else {
6007 Some(snapshot)
6008 }
6009 } else {
6010 None
6011 }
6012 } else {
6013 None
6014 };
6015
6016 let chain_id_verification = required_verification(
6017 self.verification.verify_chain_id().await,
6018 "Blockchain chain ID",
6019 )?;
6020 let checkpoint_verification = required_verification(
6021 self.verification.verify_checkpoint().await,
6022 "Blockchain checkpoint",
6023 )?;
6024 let checkpoint = checkpoint_verification.value;
6025 let finalized_verification = required_verification(
6026 self.verification.verify_finalized_header().await,
6027 "Blockchain finalized header",
6028 )?;
6029 let finalized = finalized_verification.value;
6030 let mut connect_decisions = vec![
6031 verification_decision(&chain_id_verification, None, None),
6032 verification_decision(
6033 &checkpoint_verification,
6034 Some(checkpoint.number),
6035 Some(checkpoint.number),
6036 ),
6037 verification_decision(
6038 &finalized_verification,
6039 Some(finalized.number),
6040 Some(finalized.number),
6041 ),
6042 ];
6043 let mut finalized_headers = if let Some(position) = position.as_ref() {
6044 let durable_tip = parse_verified_header(&position.finalized_tip)?;
6045 anyhow::ensure!(
6046 durable_tip.number >= checkpoint.number,
6047 "Durable finalized header tip precedes the trusted checkpoint"
6048 );
6049 let durable_tip_verification = required_verification(
6050 self.verification.verify_block(durable_tip.number).await,
6051 "Blockchain durable finalized tip",
6052 )?;
6053 anyhow::ensure!(
6054 durable_tip_verification.value == durable_tip,
6055 "Durable finalized header tip conflicts with independent sources"
6056 );
6057 connect_decisions.push(verification_decision(
6058 &durable_tip_verification,
6059 Some(durable_tip.number),
6060 Some(durable_tip.number),
6061 ));
6062 vec![durable_tip]
6063 } else {
6064 vec![checkpoint]
6065 };
6066 let mut ancestry_cursor = *finalized_headers
6067 .last()
6068 .expect("finalized header ledger is nonempty");
6069 anyhow::ensure!(
6070 finalized.number >= ancestry_cursor.number,
6071 "Verified finalized height regressed below the durable finalized header tip"
6072 );
6073
6074 while ancestry_cursor.number < finalized.number {
6075 let end = ancestry_cursor
6076 .number
6077 .saturating_add(4_096)
6078 .min(finalized.number);
6079 let start = ancestry_cursor.number.saturating_add(1);
6080 let headers_verification = required_verification(
6081 self.verification
6082 .verify_header_window(ancestry_cursor, end)
6083 .await,
6084 "Blockchain finalized ancestry",
6085 )?;
6086 let headers = &headers_verification.value;
6087 ancestry_cursor = *headers
6088 .last()
6089 .expect("nonempty ancestry window advances the cursor");
6090 finalized_headers.extend(headers.iter().copied());
6091 connect_decisions.push(verification_decision(
6092 &headers_verification,
6093 Some(start),
6094 Some(end),
6095 ));
6096 }
6097 anyhow::ensure!(
6098 ancestry_cursor == finalized,
6099 "Verified finalized header conflicts with its ancestry window"
6100 );
6101 let nonce_verification = required_verification(
6102 self.verification
6103 .verify_transaction_count(&self.wallet_address, finalized.number)
6104 .await,
6105 "Blockchain finalized transaction count",
6106 )?;
6107 let observed_canonical_nonce = nonce_verification.value;
6108 let next_canonical_nonce = position
6109 .as_ref()
6110 .map_or(observed_canonical_nonce, |position| {
6111 position.next_canonical_nonce
6112 });
6113
6114 if let Some(position) = position.as_ref() {
6115 log::debug!(
6116 "Resumed execution verification ledger at nonce revision {} with observed finalized nonce {}",
6117 position.revision,
6118 observed_canonical_nonce,
6119 );
6120 }
6121 connect_decisions.push(verification_decision(
6122 &nonce_verification,
6123 Some(finalized.number),
6124 Some(finalized.number),
6125 ));
6126 let deployment_verification = required_verification(
6127 self.verification
6128 .verify_deployment_manifest(&verification.deployment_manifest, finalized.number)
6129 .await,
6130 "Blockchain deployment manifest",
6131 )?;
6132 connect_decisions.push(verification_decision(
6133 &deployment_verification,
6134 Some(finalized.number),
6135 Some(finalized.number),
6136 ));
6137 connect_decisions.extend(
6138 verify_connect_capabilities(
6139 &self.verification,
6140 &verification.deployment_manifest,
6141 self.wallet_address,
6142 self.weth_address,
6143 finalized.number,
6144 )
6145 .await?,
6146 );
6147 let migration = if let Some(snapshot) = migration_snapshot {
6148 Some(
6149 self.build_execution_verification_migration(
6150 snapshot,
6151 finalized,
6152 &finalized_headers,
6153 &nonce_verification,
6154 )
6155 .await?,
6156 )
6157 } else {
6158 None
6159 };
6160
6161 if let Some(database) = self.cache.database.as_ref() {
6162 let identities = std::iter::once(&verification.authoritative)
6163 .chain(
6164 verification
6165 .verifiers
6166 .iter()
6167 .map(|provider| &provider.identity),
6168 )
6169 .collect::<Vec<_>>();
6170 let provider_ids = identities
6171 .iter()
6172 .map(|identity| identity.provider_id.clone())
6173 .collect::<Vec<_>>();
6174 let operator_ids = identities
6175 .iter()
6176 .map(|identity| identity.operator_id.clone())
6177 .collect::<Vec<_>>();
6178 let failure_domain_ids = identities
6179 .iter()
6180 .flat_map(|identity| identity.failure_domain_ids.iter().cloned())
6181 .collect::<Vec<_>>();
6182 let finalized_headers = finalized_headers
6183 .iter()
6184 .map(durable_verified_header)
6185 .collect::<Vec<_>>();
6186 database
6187 .ensure_execution_verification_schema(&ExecutionVerificationBootstrap {
6188 chain_id: self.chain.chain_id,
6189 wallet_address: &self.config.wallet_address,
6190 manifest_version: &verification.manifest_version,
6191 manifest_digest: &verification.manifest_digest,
6192 checkpoint_number: checkpoint.number,
6193 checkpoint_hash: &checkpoint.hash.to_string(),
6194 checkpoint_parent_hash: &checkpoint.parent_hash.to_string(),
6195 checkpoint_timestamp: checkpoint.timestamp,
6196 checkpoint_base_fee_per_gas: checkpoint.base_fee_per_gas,
6197 finalized_headers: &finalized_headers,
6198 next_canonical_nonce,
6199 observed_canonical_nonce,
6200 provider_ids: &provider_ids,
6201 operator_ids: &operator_ids,
6202 failure_domain_ids: &failure_domain_ids,
6203 decisions: &connect_decisions,
6204 migration: migration.as_ref(),
6205 })
6206 .await?;
6207 }
6208
6209 let payload_connect_lease = if let (Some(database), Some(keys)) =
6210 (self.cache.database.as_ref(), self.payload_keys.as_deref())
6211 {
6212 Some(
6213 database
6214 .require_execution_payload_storage(keys, self.payload_policy(), 100)
6215 .await?,
6216 )
6217 } else {
6218 None
6219 };
6220
6221 let private_key = Zeroizing::new(
6224 std::env::var(&self.config.signer_private_key_env).map_err(|_| {
6225 anyhow::anyhow!(
6226 "Signer private key environment variable '{}' is not set",
6227 self.config.signer_private_key_env
6228 )
6229 })?,
6230 );
6231 let encoded_key = private_key.trim();
6232 let encoded_key = encoded_key.strip_prefix("0x").unwrap_or(encoded_key);
6233 let key_bytes = Zeroizing::new(hex::decode_array::<32>(encoded_key).map_err(|_| {
6234 anyhow::anyhow!(
6235 "Signer private key in '{}' is not a valid hex private key",
6236 self.config.signer_private_key_env
6237 )
6238 })?);
6239 let signer = PrivateKeySigner::from_slice(&key_bytes[..]).map_err(|_| {
6240 anyhow::anyhow!(
6241 "Signer private key in '{}' is not a valid secp256k1 private key",
6242 self.config.signer_private_key_env
6243 )
6244 })?;
6245
6246 if signer.address() != self.wallet_address {
6247 anyhow::bail!(
6248 "Signer address {} derived from '{}' does not match configured wallet address {}",
6249 signer.address(),
6250 self.config.signer_private_key_env,
6251 self.wallet_address
6252 );
6253 }
6254
6255 self.signer = Some(Arc::new(signer));
6256 drop(payload_connect_lease);
6257
6258 if self.cache.has_database()
6259 && let Err(e) = self.reconcile_unresolved_execution().await
6260 {
6261 self.signer = None;
6262 return Err(e);
6263 }
6264
6265 if let Err(e) = self.refresh_wallet_balances().await {
6266 self.signer = None;
6267 return Err(e);
6268 }
6269 self.core.set_connected();
6270 setup_guard.disarm();
6271 log::info!(
6272 "Blockchain execution client connected on chain {}",
6273 self.chain.name
6274 );
6275 Ok(())
6276 }
6277
6278 async fn disconnect(&mut self) -> anyhow::Result<()> {
6279 self.pending_tasks.begin_shutdown();
6280 let tasks_result = self
6281 .pending_tasks
6282 .finish_shutdown(Duration::from_secs(5), Duration::from_secs(2))
6283 .await;
6284 self.signer = None;
6285 self.core.set_disconnected();
6286 tasks_result
6287 .map_err(|e| anyhow::anyhow!("Failed to terminate blockchain submissions: {e}"))?;
6288 Ok(())
6289 }
6290
6291 async fn generate_order_status_report(
6292 &self,
6293 _cmd: &GenerateOrderStatusReport,
6294 ) -> anyhow::Result<Option<OrderStatusReport>> {
6295 anyhow::bail!("{VENUE_EXECUTION_REPORTS_UNSUPPORTED}");
6296 }
6297
6298 async fn generate_order_status_reports(
6299 &self,
6300 _cmd: &GenerateOrderStatusReports,
6301 ) -> anyhow::Result<Vec<OrderStatusReport>> {
6302 anyhow::bail!("{VENUE_EXECUTION_REPORTS_UNSUPPORTED}");
6303 }
6304
6305 async fn generate_fill_reports(
6306 &self,
6307 _cmd: GenerateFillReports,
6308 ) -> anyhow::Result<Vec<FillReport>> {
6309 anyhow::bail!("{VENUE_EXECUTION_REPORTS_UNSUPPORTED}");
6310 }
6311
6312 async fn generate_position_status_reports(
6313 &self,
6314 _cmd: &GeneratePositionStatusReports,
6315 ) -> anyhow::Result<Vec<PositionStatusReport>> {
6316 anyhow::bail!("{VENUE_EXECUTION_REPORTS_UNSUPPORTED}");
6317 }
6318
6319 async fn generate_mass_status(
6320 &self,
6321 _lookback_mins: Option<u64>,
6322 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
6323 log::info!(
6326 "Mass status is not supported on the blockchain execution client; skipping venue reconciliation"
6327 );
6328 Ok(None)
6329 }
6330}
6331
6332fn validate_order(order: &impl Order) -> Result<(), OrderDeniedReason> {
6333 if order.is_reduce_only() {
6334 return Err(OrderDeniedReason::UnsupportedReduceOnly);
6335 }
6336
6337 Ok(())
6338}
6339
6340#[cfg(test)]
6341mod tests {
6342 #![allow(
6343 clippy::large_futures,
6344 reason = "exercises inline transaction futures without changing their allocation behavior"
6345 )]
6346
6347 use std::{
6348 cell::RefCell,
6349 rc::Rc,
6350 sync::atomic::{AtomicU64, Ordering},
6351 };
6352
6353 use alloy::{
6354 primitives::{address, aliases::I24},
6355 sol_types::SolValue,
6356 };
6357 use nautilus_common::{
6358 cache::Cache,
6359 clock::{Clock, VirtualClock},
6360 live::runner::replace_exec_event_sender,
6361 messages::{ExecutionEvent, execution::TradingCommand},
6362 testing::wait_until_async,
6363 };
6364 use nautilus_core::UUID4;
6365 use nautilus_infrastructure::sql::pg::{PostgresConnectOptions, get_postgres_connect_options};
6366 use nautilus_live::{
6367 execution::{
6368 config::ExecutionManagerConfig,
6369 submission::{SubmissionRecoveryExhausted, SubmissionRecoverySource},
6370 },
6371 manager::ExecutionManager,
6372 };
6373 use nautilus_model::{
6374 defi::{
6375 PoolProfiler,
6376 chain::chains,
6377 data::block::BlockPosition,
6378 pool_analysis::{
6379 position::PoolPosition,
6380 snapshot::{PoolAnalytics, PoolSnapshot, PoolState},
6381 },
6382 tick_map::{tick::PoolTick, tick_math::get_tick_at_sqrt_ratio},
6383 },
6384 enums::AccountType,
6385 events::OrderEventAny,
6386 identifiers::{OrderListId, StrategyId, TraderId},
6387 orders::{OrderList, OrderTestBuilder},
6388 types::Price,
6389 };
6390 use rstest::rstest;
6391 use sqlx::postgres::{PgAdvisoryLock, PgAdvisoryLockKey, PgPoolOptions};
6392
6393 use super::*;
6394 use crate::{
6395 cache::database::tests::connect_test_database,
6396 config::{
6397 BlockchainCallEdgeManifest, BlockchainChainAnchorConfig, BlockchainContractManifest,
6398 BlockchainContractProbe, BlockchainContractRole, BlockchainDeploymentManifest,
6399 BlockchainPoolManifest, BlockchainProviderIdentity, BlockchainTokenManifest,
6400 BlockchainVerificationConfig, BlockchainVerificationProviderConfig, QuoteSpendLimit,
6401 },
6402 constants::BLOCKCHAIN_VENUE,
6403 exchanges::arbitrum::UNISWAP_V3,
6404 rpc::http::{
6405 EXECUTION_RPC_TIMEOUT_SECS,
6406 tests::mock::{MockRpcState, start_mock_rpc_server},
6407 },
6408 };
6409
6410 async fn poll_for_receipt(
6413 http_rpc_client: &BlockchainHttpRpcClient,
6414 tx_hash: &B256,
6415 max_polls: u32,
6416 interval: Duration,
6417 ) -> anyhow::Result<Option<RpcTransactionReceipt>> {
6418 let mut last_error = None;
6419 let mut observed_pending = false;
6420
6421 for attempt in 0..max_polls {
6422 if attempt > 0 {
6423 tokio::time::sleep(interval).await;
6424 }
6425
6426 match http_rpc_client.get_transaction_receipt(tx_hash).await {
6427 Ok(Some(receipt)) => return Ok(Some(receipt)),
6428 Ok(None) => observed_pending = true,
6429 Err(e) => {
6430 log::warn!(
6431 "Receipt poll {}/{} for transaction {tx_hash} failed: {e}",
6432 attempt + 1,
6433 max_polls
6434 );
6435 last_error = Some(e);
6436 }
6437 }
6438 }
6439
6440 if !observed_pending && let Some(e) = last_error {
6441 return Err(e);
6442 }
6443
6444 Ok(None)
6445 }
6446
6447 const CHAIN_ID_ARBITRUM: &str =
6448 include_str!("../../test_data/execution/rpc_eth_chain_id_arbitrum.json");
6449 const CHAIN_ID_ETHEREUM: &str =
6450 include_str!("../../test_data/execution/rpc_eth_chain_id_ethereum.json");
6451 const GET_CODE_DEPLOYED: &str =
6452 include_str!("../../test_data/execution/rpc_eth_get_code_deployed.json");
6453 const GET_CODE_EMPTY: &str =
6454 include_str!("../../test_data/execution/rpc_eth_get_code_empty.json");
6455 const GET_BALANCE: &str = include_str!("../../test_data/execution/rpc_eth_get_balance.json");
6456 const GET_BALANCE_ZERO: &str =
6457 include_str!("../../test_data/execution/rpc_eth_get_balance_zero.json");
6458 const GET_BALANCE_INSUFFICIENT: &str =
6459 include_str!("../../test_data/execution/rpc_eth_get_balance_insufficient.json");
6460 const CALL_BALANCE: &str = include_str!("../../test_data/execution/rpc_eth_call_balance.json");
6461 const CALL_BALANCE_AFTER_WRAP: &str =
6462 include_str!("../../test_data/execution/rpc_eth_call_balance_after_wrap.json");
6463 const CALL_BALANCE_WETH: &str =
6464 include_str!("../../test_data/execution/rpc_eth_call_balance_weth.json");
6465 const CALL_BALANCE_USDC: &str =
6466 include_str!("../../test_data/execution/rpc_eth_call_balance_usdc.json");
6467 const CALL_BALANCE_WETH_UPDATED: &str =
6468 include_str!("../../test_data/execution/rpc_eth_call_balance_weth_updated.json");
6469 const CALL_BALANCE_USDC_UPDATED: &str =
6470 include_str!("../../test_data/execution/rpc_eth_call_balance_usdc_updated.json");
6471 const CALL_BOOL_TRUE: &str =
6472 include_str!("../../test_data/execution/rpc_eth_call_bool_true.json");
6473 const CALL_EMPTY: &str = include_str!("../../test_data/execution/rpc_eth_call_empty.json");
6474 const CALL_ZERO: &str = include_str!("../../test_data/execution/rpc_eth_call_zero.json");
6475 const CALL_ALLOWANCE: &str =
6476 include_str!("../../test_data/execution/rpc_eth_call_allowance.json");
6477 const CALL_ALLOWANCE_1000: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x00000000000000000000000000000000000000000000000000000000000003e8\"}";
6478 const CALL_ALLOWANCE_MAX: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"}";
6479 const CALL_FACTORY: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x0000000000000000000000001f98431c8ad98523631ae4a59f267346ea31f984\"}";
6480 const CALL_WETH: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x00000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1\"}";
6481 const CALL_USDC: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831\"}";
6482 const CALL_FEE_500: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x00000000000000000000000000000000000000000000000000000000000001f4\"}";
6483 const CALL_POOL: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x000000000000000000000000c6962004f452be9203591991d15f6b388e09e8d0\"}";
6484 const CALL_REVERTED: &str =
6485 r#"{"jsonrpc":"2.0","id":1,"error":{"code":3,"message":"execution reverted"}}"#;
6486 const STORAGE_ZERO: &str = r#"{"jsonrpc":"2.0","id":1,"result":"0x0000000000000000000000000000000000000000000000000000000000000000"}"#;
6487 const TRACE_UNKNOWN_TRANSACTION: &str =
6488 r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"transaction not found"}}"#;
6489 const CALL_DECIMALS_18: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x0000000000000000000000000000000000000000000000000000000000000012\"}";
6490 const CALL_DECIMALS_6: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x0000000000000000000000000000000000000000000000000000000000000006\"}";
6491 const TRANSACTION_COUNT: &str =
6492 include_str!("../../test_data/execution/rpc_eth_get_transaction_count.json");
6493 const TRANSACTION_COUNT_NEXT: &str =
6494 include_str!("../../test_data/execution/rpc_eth_get_transaction_count_next.json");
6495 const ESTIMATE_GAS: &str = include_str!("../../test_data/execution/rpc_eth_estimate_gas.json");
6496 const MAX_PRIORITY_FEE: &str =
6497 include_str!("../../test_data/execution/rpc_eth_max_priority_fee_per_gas.json");
6498 const BLOCK_BY_NUMBER: &str =
6499 include_str!("../../test_data/execution/rpc_eth_get_block_by_number.json");
6500 const BLOCK_CANONICAL: &str =
6501 include_str!("../../test_data/execution/rpc_eth_get_block_canonical.json");
6502 const BLOCK_FINALIZED: &str =
6503 include_str!("../../test_data/execution/rpc_eth_get_block_finalized.json");
6504 const RECEIPT_SUCCESS: &str =
6505 include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_success.json");
6506 const RECEIPT_REVERTED: &str =
6507 include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_reverted.json");
6508 const RECEIPT_NULL: &str =
6509 include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_null.json");
6510 const SEND_RAW_TRANSACTION: &str =
6511 include_str!("../../test_data/execution/rpc_eth_send_raw_transaction.json");
6512 const SEND_RAW_TRANSACTION_REJECTED: &str =
6513 include_str!("../../test_data/execution/rpc_eth_send_raw_transaction_rejected.json");
6514 const SEND_RAW_TRANSACTION_NONCE_TOO_LOW: &str =
6515 include_str!("../../test_data/execution/rpc_eth_send_raw_transaction_nonce_too_low.json");
6516 const RPC_METHOD_NOT_FOUND: &str =
6517 include_str!("../../test_data/execution/rpc_error_method_not_found.json");
6518
6519 const WALLET: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
6520 const ROUTER: &str = "0xE592427A0AEce92De3Edee1F18E0157C05861564";
6521 const WETH: &str = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1";
6522 const USDC: &str = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";
6523 const TEST_TIMEOUT: Duration = Duration::from_secs(10);
6524
6525 const WETH_ADDRESS: Address = address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1");
6526 const USDC_ADDRESS: Address = address!("af88d065e77c8cC2239327C5EDb3A432268e5831");
6527 const ROUTER_ADDRESS: Address = address!("E592427A0AEce92De3Edee1F18E0157C05861564");
6528
6529 const TEST_PRIVATE_KEY: &str =
6531 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
6532
6533 const BALANCE_OF_SELECTOR: &str = "0x70a08231";
6534 const ALLOWANCE_SELECTOR: &str = "0xdd62ed3e";
6535 const POOL_TOKEN0_SELECTOR: &str = "0x0dfe1681";
6536 const POOL_TOKEN1_SELECTOR: &str = "0xd21220a7";
6537 const POOL_FEE_SELECTOR: &str = "0xddca3f43";
6538 const DECIMALS_SELECTOR: &str = "0x313ce567";
6539 const FACTORY_SELECTOR: &str = "0xc45a0155";
6540 const WETH9_SELECTOR: &str = "0x4aa4a4fc";
6541 const GET_POOL_SELECTOR: &str = "0x1698ee82";
6542 const QUOTE_EXACT_INPUT_SELECTOR: &str = "0xc6a5026a";
6543 const QUOTE_EXACT_OUTPUT_SELECTOR: &str = "0xbd21704a";
6544
6545 fn test_pool() -> Pool {
6546 let chain = Arc::new(chains::ARBITRUM.clone());
6547 let dex = Arc::clone(&UNISWAP_V3.dex);
6548 let weth = Token::new(
6549 Arc::clone(&chain),
6550 address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
6551 "Wrapped Ether".to_string(),
6552 "WETH".to_string(),
6553 18,
6554 );
6555 let usdc = Token::new(
6556 Arc::clone(&chain),
6557 address!("af88d065e77c8cC2239327C5EDb3A432268e5831"),
6558 "USD Coin".to_string(),
6559 "USDC".to_string(),
6560 6,
6561 );
6562
6563 Pool::new(
6564 chain,
6565 dex,
6566 address!("C6962004f452bE9203591991D15f6b388e09E8D0"),
6567 PoolIdentifier::from_address(address!("C6962004f452bE9203591991D15f6b388e09E8D0")),
6568 55_000_000,
6569 weth,
6570 usdc,
6571 Some(500),
6572 Some(10),
6573 UnixNanos::default(),
6574 )
6575 }
6576
6577 fn test_config(http_rpc_url: String) -> BlockchainExecutionClientConfig {
6578 test_config_with_signer_env(http_rpc_url, "BLOCKCHAIN_TEST_PRIVATE_KEY")
6579 }
6580
6581 fn test_config_with_signer_env(
6582 http_rpc_url: String,
6583 signer_env: &str,
6584 ) -> BlockchainExecutionClientConfig {
6585 let verifier_separator = if http_rpc_url.contains('?') { '&' } else { '?' };
6586 let code_hash = keccak256(
6587 hex::decode("6080604052348015600e575f5ffd5b5060").expect("valid test bytecode"),
6588 )
6589 .to_string();
6590 let result = |response: &str| {
6591 serde_json::from_str::<serde_json::Value>(response).unwrap()["result"]
6592 .as_str()
6593 .unwrap()
6594 .to_string()
6595 };
6596 let probe = |call_data: &str, expected_output: String| BlockchainContractProbe {
6597 call_data: call_data.to_string(),
6598 expected_output,
6599 };
6600 let contract = |address: &str, role| {
6601 let probes = match role {
6602 BlockchainContractRole::Router => vec![
6603 probe(FACTORY_SELECTOR, result(CALL_FACTORY)),
6604 probe(WETH9_SELECTOR, result(CALL_WETH)),
6605 ],
6606 BlockchainContractRole::Factory => vec![probe(
6607 &hex::encode_prefixed(
6608 UniswapV3Factory::getPoolCall {
6609 tokenA: WETH_ADDRESS,
6610 tokenB: USDC_ADDRESS,
6611 fee: U24::try_from(500u32).unwrap(),
6612 }
6613 .abi_encode(),
6614 ),
6615 result(CALL_POOL),
6616 )],
6617 BlockchainContractRole::WrappedNative => {
6618 vec![probe(DECIMALS_SELECTOR, result(CALL_DECIMALS_18))]
6619 }
6620 BlockchainContractRole::Quote => {
6621 vec![probe(FACTORY_SELECTOR, result(CALL_FACTORY))]
6622 }
6623 BlockchainContractRole::Token => {
6624 vec![probe(DECIMALS_SELECTOR, result(CALL_DECIMALS_6))]
6625 }
6626 BlockchainContractRole::Pool => vec![
6627 probe(POOL_TOKEN0_SELECTOR, result(CALL_WETH)),
6628 probe(POOL_TOKEN1_SELECTOR, result(CALL_USDC)),
6629 probe(POOL_FEE_SELECTOR, result(CALL_FEE_500)),
6630 ],
6631 BlockchainContractRole::Implementation => Vec::new(),
6632 };
6633 BlockchainContractManifest {
6634 address: address.to_string(),
6635 role,
6636 runtime_code_hash: code_hash.clone(),
6637 proxy: None,
6638 probes,
6639 }
6640 };
6641 let deployment_manifest = BlockchainDeploymentManifest {
6642 version: "test-v1".to_string(),
6643 chain_id: chains::ARBITRUM.chain_id,
6644 chain_name: chains::ARBITRUM.name.to_string(),
6645 contracts: vec![
6646 contract(ROUTER, BlockchainContractRole::Router),
6647 contract(
6648 "0x1F98431c8aD98523631AE4a59f267346ea31F984",
6649 BlockchainContractRole::Factory,
6650 ),
6651 contract(WETH, BlockchainContractRole::WrappedNative),
6652 contract(
6653 "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
6654 BlockchainContractRole::Quote,
6655 ),
6656 contract(USDC, BlockchainContractRole::Token),
6657 contract(
6658 "0xC6962004f452bE9203591991D15f6b388e09E8D0",
6659 BlockchainContractRole::Pool,
6660 ),
6661 ],
6662 tokens: vec![
6663 BlockchainTokenManifest {
6664 address: WETH.to_string(),
6665 name: "Wrapped Ether".to_string(),
6666 symbol: "WETH".to_string(),
6667 decimals: 18,
6668 asset_role: "both".to_string(),
6669 },
6670 BlockchainTokenManifest {
6671 address: USDC.to_string(),
6672 name: "USD Coin".to_string(),
6673 symbol: "USDC".to_string(),
6674 decimals: 6,
6675 asset_role: "both".to_string(),
6676 },
6677 ],
6678 pools: vec![BlockchainPoolManifest {
6679 address: "0xC6962004f452bE9203591991D15f6b388e09E8D0".to_string(),
6680 token0: WETH.to_string(),
6681 token1: USDC.to_string(),
6682 fee: 500,
6683 factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984".to_string(),
6684 quote_contract: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e".to_string(),
6685 }],
6686 call_edges: ["swap_sell", "swap_buy"]
6687 .into_iter()
6688 .map(|purpose| BlockchainCallEdgeManifest {
6689 purpose: purpose.to_string(),
6690 caller: ROUTER.to_string(),
6691 target: "0xC6962004f452bE9203591991D15f6b388e09E8D0".to_string(),
6692 call_type: "call".to_string(),
6693 })
6694 .collect(),
6695 };
6696 let manifest_digest =
6697 keccak256(serde_json::to_vec(&deployment_manifest).unwrap()).to_string();
6698 let verification = BlockchainVerificationConfig {
6699 authoritative: BlockchainProviderIdentity {
6700 provider_id: "authoritative".to_string(),
6701 operator_id: "operator-a".to_string(),
6702 failure_domain_ids: vec!["domain-a".to_string()],
6703 },
6704 verifiers: vec![
6705 BlockchainVerificationProviderConfig {
6706 identity: BlockchainProviderIdentity {
6707 provider_id: "verifier-a".to_string(),
6708 operator_id: "operator-b".to_string(),
6709 failure_domain_ids: vec!["domain-b".to_string()],
6710 },
6711 http_rpc_url: format!("{http_rpc_url}{verifier_separator}source=verifier-a")
6712 .into(),
6713 },
6714 BlockchainVerificationProviderConfig {
6715 identity: BlockchainProviderIdentity {
6716 provider_id: "verifier-b".to_string(),
6717 operator_id: "operator-c".to_string(),
6718 failure_domain_ids: vec!["domain-c".to_string()],
6719 },
6720 http_rpc_url: format!("{http_rpc_url}{verifier_separator}source=verifier-b")
6721 .into(),
6722 },
6723 ],
6724 chain_anchor: BlockchainChainAnchorConfig {
6725 chain_id: chains::ARBITRUM.chain_id,
6726 chain_name: chains::ARBITRUM.name.to_string(),
6727 checkpoint_height: 30_346_560,
6728 checkpoint_hash:
6729 "0x1111111111111111111111111111111111111111111111111111111111111111".to_string(),
6730 checkpoint_timestamp: 1_761_888_800,
6731 max_head_skew_blocks: 3,
6732 max_head_age_secs: u64::MAX,
6733 max_future_drift_secs: u64::MAX,
6734 },
6735 manifest_version: "test-v1".to_string(),
6736 manifest_digest,
6737 deployment_manifest,
6738 };
6739 BlockchainExecutionClientConfig::builder()
6740 .client_id(AccountId::from("BLOCKCHAIN-001"))
6741 .chain(chains::ARBITRUM.clone())
6742 .wallet_address(WALLET.to_string())
6743 .http_rpc_url(http_rpc_url.into())
6744 .verification(verification)
6745 .signer_private_key_env(signer_env.to_string())
6746 .router_addresses(vec![ROUTER.to_string()])
6747 .weth_address(WETH.to_string())
6748 .max_fee_per_gas_wei(1_000_000_000)
6749 .base_fee_buffer_bps(2_000)
6750 .gas_limit(1_000_000)
6751 .gas_buffer_bps(2_000)
6752 .allowed_token_pairs(vec![(WETH.to_string(), USDC.to_string())])
6753 .slippage_bps(50)
6754 .max_slippage_bps(200)
6755 .max_order_amount(1_000_000_000_000_000_000)
6756 .deadline_seconds(300)
6757 .max_quote_age_blocks(100)
6758 .receipt_timeout_secs(1)
6759 .build()
6760 }
6761
6762 fn buy_test_config(http_rpc_url: String) -> BlockchainExecutionClientConfig {
6763 let max_amount = expected_buy_amount_in().to_string();
6764 let mut config = test_config(http_rpc_url);
6765 config.allowed_token_pairs = Some(vec![
6766 (WETH.to_string(), USDC.to_string()),
6767 (USDC.to_string(), WETH.to_string()),
6768 ]);
6769 config.quote_spend_limits = Some(vec![quote_spend_limit(USDC, WETH, 6, &max_amount)]);
6770 config
6771 }
6772
6773 fn refresh_test_manifest_digest(config: &mut BlockchainExecutionClientConfig) {
6774 let verification = config.verification.as_mut().unwrap();
6775 verification.manifest_digest =
6776 keccak256(serde_json::to_vec(&verification.deployment_manifest).unwrap()).to_string();
6777 }
6778
6779 fn quote_spend_limit(
6780 token_in: &str,
6781 token_out: &str,
6782 spend_token_decimals: u8,
6783 max_amount: &str,
6784 ) -> QuoteSpendLimit {
6785 QuoteSpendLimit::builder()
6786 .token_in(token_in.to_string())
6787 .token_out(token_out.to_string())
6788 .spend_token(token_in.to_string())
6789 .spend_token_decimals(spend_token_decimals)
6790 .max_amount(max_amount.to_string())
6791 .build()
6792 }
6793
6794 fn test_client_from_config(
6795 config: BlockchainExecutionClientConfig,
6796 pool: Pool,
6797 ) -> BlockchainExecutionClient {
6798 test_client_result(config, pool).unwrap()
6799 }
6800
6801 fn test_client_result(
6802 config: BlockchainExecutionClientConfig,
6803 pool: Pool,
6804 ) -> anyhow::Result<BlockchainExecutionClient> {
6805 test_client_and_cache(config, pool).map(|(client, _)| client)
6806 }
6807
6808 fn test_client_and_cache(
6809 config: BlockchainExecutionClientConfig,
6810 pool: Pool,
6811 ) -> anyhow::Result<(BlockchainExecutionClient, Rc<RefCell<Cache>>)> {
6812 let cache = Rc::new(RefCell::new(Cache::default()));
6813 cache.borrow_mut().add_pool(pool).unwrap();
6814 let core = ExecutionClientCore::new(
6815 TraderId::from("TRADER-001"),
6816 ClientId::from("BLOCKCHAIN-001"),
6817 *BLOCKCHAIN_VENUE,
6818 OmsType::Netting,
6819 AccountId::from("BLOCKCHAIN-001"),
6820 AccountType::Wallet,
6821 None,
6822 Rc::clone(&cache),
6823 );
6824
6825 let client = BlockchainExecutionClient::new(core, config)?;
6826 Ok((client, cache))
6827 }
6828
6829 fn test_client(http_rpc_url: String) -> BlockchainExecutionClient {
6830 test_client_from_config(test_config(http_rpc_url), test_pool())
6831 }
6832
6833 async fn client_with_mock_rpc(
6834 state: MockRpcState,
6835 ) -> (BlockchainExecutionClient, MockRpcState) {
6836 let addr = start_mock_rpc_server(state.clone()).await;
6837 (test_client(format!("http://{addr}")), state)
6838 }
6839
6840 async fn client_with_token_mock_rpc(
6841 state: MockRpcState,
6842 signer_env: &str,
6843 ) -> (BlockchainExecutionClient, MockRpcState, Rc<RefCell<Cache>>) {
6844 let state = with_connect_capabilities(state);
6845 let addr = start_mock_rpc_server(state.clone()).await;
6846 let pool = test_pool();
6847 let tokens = [pool.token0.clone(), pool.token1.clone()];
6848 let mut config = test_config_with_signer_env(format!("http://{addr}"), signer_env);
6849 config.tokens = Some(vec![WETH.to_string(), USDC.to_string()]);
6850 let (mut client, cache) = test_client_and_cache(config, pool).unwrap();
6851 for token in tokens {
6852 client.cache.add_token(token).await.unwrap();
6853 }
6854 (client, state, cache)
6855 }
6856
6857 fn execution_rpc_state() -> MockRpcState {
6858 MockRpcState::default()
6859 .with_receipt_hash_from_request()
6860 .with_response("eth_chainId", CHAIN_ID_ARBITRUM)
6861 .with_response("eth_getCode", GET_CODE_DEPLOYED)
6862 .with_response("eth_getBalance", GET_BALANCE)
6863 .with_response("eth_getBlockByNumber", BLOCK_BY_NUMBER)
6864 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
6865 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", BLOCK_CANONICAL)
6866 .with_parameter_response("eth_getBlockByNumber", "finalized", BLOCK_FINALIZED)
6867 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d42", BLOCK_FINALIZED)
6868 .with_response("eth_maxPriorityFeePerGas", MAX_PRIORITY_FEE)
6869 .with_call_response(FACTORY_SELECTOR, CALL_FACTORY)
6870 .with_call_response(WETH9_SELECTOR, CALL_WETH)
6871 .with_call_response(GET_POOL_SELECTOR, CALL_POOL)
6872 .with_call_response(POOL_TOKEN0_SELECTOR, CALL_WETH)
6873 .with_call_response(POOL_TOKEN1_SELECTOR, CALL_USDC)
6874 .with_call_response(POOL_FEE_SELECTOR, CALL_FEE_500)
6875 .with_contract_call_response(WETH, DECIMALS_SELECTOR, CALL_DECIMALS_18)
6876 .with_contract_call_response(USDC, DECIMALS_SELECTOR, CALL_DECIMALS_6)
6877 .with_call_response(
6878 QUOTE_EXACT_INPUT_SELECTOR,
6879 "e_response(expected_sell_quote_amount()),
6880 )
6881 .with_call_response(
6882 QUOTE_EXACT_OUTPUT_SELECTOR,
6883 "e_response(expected_buy_amount_in()),
6884 )
6885 }
6886
6887 fn ready_rpc_state() -> MockRpcState {
6888 with_connect_capabilities(execution_rpc_state())
6889 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE)
6890 .with_call_response(ALLOWANCE_SELECTOR, CALL_ALLOWANCE)
6891 }
6892
6893 fn with_connect_capabilities(state: MockRpcState) -> MockRpcState {
6894 state
6895 .with_response("eth_getStorageAt", STORAGE_ZERO)
6896 .with_response("eth_estimateGas", ESTIMATE_GAS)
6897 .with_parameter_response(
6898 "debug_traceTransaction",
6899 &B256::ZERO.to_string(),
6900 TRACE_UNKNOWN_TRANSACTION,
6901 )
6902 }
6903
6904 fn signing_rpc_state() -> MockRpcState {
6905 ready_rpc_state()
6906 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
6907 .with_response("eth_estimateGas", ESTIMATE_GAS)
6908 }
6909
6910 fn broadcast_rpc_state() -> MockRpcState {
6911 execution_rpc_state()
6912 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
6913 .with_response("eth_estimateGas", ESTIMATE_GAS)
6914 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS)
6915 .with_send_raw_transaction_echo()
6916 .with_call_response_sequence(ALLOWANCE_SELECTOR, &[CALL_ZERO, CALL_ALLOWANCE_1000])
6917 }
6918
6919 async fn expected_wrap_tx_hash(value: U256) -> B256 {
6920 expected_tx_hash(
6921 WETH_ADDRESS,
6922 value,
6923 Bytes::from(nautilus_core::hex::decode("d0e30db0").unwrap()),
6924 )
6925 .await
6926 }
6927
6928 async fn expected_approve_tx_hash(amount: U256) -> B256 {
6929 let calldata = ERC20::approveCall {
6930 spender: ROUTER_ADDRESS,
6931 amount,
6932 }
6933 .abi_encode();
6934 expected_tx_hash(WETH_ADDRESS, U256::ZERO, Bytes::from(calldata)).await
6935 }
6936
6937 async fn expected_tx_hash(to: Address, value: U256, input: Bytes) -> B256 {
6938 let expected_tx =
6941 build_eip1559_transaction(42161, 7, 78_000, 130_000_000, 10_000_000, to, value, input);
6942 let (expected_hash, _) = sign_eip1559_transaction(
6943 expected_tx,
6944 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
6945 )
6946 .await
6947 .unwrap();
6948 expected_hash
6949 }
6950
6951 async fn await_recorded_requests(state: &MockRpcState, method: &str, expected: usize) {
6952 wait_until_async(
6953 || async {
6954 state
6955 .recorded_requests()
6956 .iter()
6957 .filter(|request| request["method"] == method)
6958 .count()
6959 >= expected
6960 },
6961 TEST_TIMEOUT,
6962 )
6963 .await;
6964 }
6965
6966 const FIXTURE_BLOCK: u64 = 30_346_560;
6969 const FIXTURE_BLOCK_PARAM: &str = "0x1cf0d40";
6970 const FIXTURE_BLOCK_HASH: &str =
6971 "0x1111111111111111111111111111111111111111111111111111111111111111";
6972 const FIXTURE_BLOCK_TIMESTAMP: u64 = 1_761_888_800;
6974 const TEST_LIQUIDITY: u128 = 1_000_000_000_000_000_000_000;
6976
6977 fn test_profiler(pool: &Pool, block_number: u64) -> PoolProfiler {
6978 test_profiler_at_block(pool, block_number, FIXTURE_BLOCK_HASH)
6979 }
6980
6981 fn test_profiler_at_block(pool: &Pool, block_number: u64, block_hash: &str) -> PoolProfiler {
6982 test_profiler_with_range(
6983 pool,
6984 block_number,
6985 block_hash,
6986 U160::from(1u128 << 96),
6987 -887_220,
6988 887_220,
6989 TEST_LIQUIDITY,
6990 )
6991 }
6992
6993 fn test_profiler_with_state(
6994 pool: &Pool,
6995 block_number: u64,
6996 sqrt_price_x96: U160,
6997 liquidity: u128,
6998 ) -> PoolProfiler {
6999 test_profiler_with_range(
7000 pool,
7001 block_number,
7002 FIXTURE_BLOCK_HASH,
7003 sqrt_price_x96,
7004 -887_220,
7005 887_220,
7006 liquidity,
7007 )
7008 }
7009
7010 fn test_profiler_with_range(
7014 pool: &Pool,
7015 block_number: u64,
7016 block_hash: &str,
7017 sqrt_price_x96: U160,
7018 tick_lower: i32,
7019 tick_upper: i32,
7020 liquidity: u128,
7021 ) -> PoolProfiler {
7022 let snapshot = PoolSnapshot::new(
7023 pool.instrument_id,
7024 PoolState {
7025 current_tick: get_tick_at_sqrt_ratio(sqrt_price_x96),
7026 price_sqrt_ratio_x96: sqrt_price_x96,
7027 liquidity,
7028 protocol_fees_token0: U256::ZERO,
7029 protocol_fees_token1: U256::ZERO,
7030 fee_protocol: 0,
7031 fee_protocol0_basis_points: None,
7032 fee_protocol1_basis_points: None,
7033 fee_growth_global_0: U256::ZERO,
7034 fee_growth_global_1: U256::ZERO,
7035 },
7036 vec![PoolPosition::new(
7037 address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"),
7038 tick_lower,
7039 tick_upper,
7040 liquidity as i128,
7041 )],
7042 vec![
7043 PoolTick::new(
7044 tick_lower,
7045 liquidity,
7046 liquidity as i128,
7047 U256::ZERO,
7048 U256::ZERO,
7049 true,
7050 0,
7051 ),
7052 PoolTick::new(
7053 tick_upper,
7054 liquidity,
7055 -(liquidity as i128),
7056 U256::ZERO,
7057 U256::ZERO,
7058 true,
7059 0,
7060 ),
7061 ],
7062 PoolAnalytics::default(),
7063 BlockPosition::new(
7064 block_number,
7065 block_hash.to_string(),
7066 BLOCK_SCOPED_SNAPSHOT_INDEX,
7067 BLOCK_SCOPED_SNAPSHOT_INDEX,
7068 )
7069 .with_block_hash(Some(block_hash.to_string())),
7070 UnixNanos::default(),
7071 UnixNanos::default(),
7072 );
7073 let mut profiler = PoolProfiler::new(Arc::new(pool.clone()));
7074 profiler.restore_from_snapshot(snapshot).unwrap();
7075 profiler
7076 }
7077
7078 fn test_market_sell_order(instrument_id: InstrumentId) -> OrderAny {
7079 market_sell_order_with_id(instrument_id, "O-SWAP-001")
7080 }
7081
7082 fn test_market_buy_order(instrument_id: InstrumentId) -> OrderAny {
7083 market_buy_order_with_id(instrument_id, "O-SWAP-BUY-001")
7084 }
7085
7086 fn submit_order_cmd(order: &OrderAny) -> SubmitOrder {
7087 SubmitOrder::new(
7088 TraderId::from("TRADER-001"),
7089 Some(ClientId::from("BLOCKCHAIN-001")),
7090 order.strategy_id(),
7091 order.instrument_id(),
7092 order.client_order_id(),
7093 order.init_event().clone(),
7094 None,
7095 None,
7096 None,
7097 UUID4::new(),
7098 UnixNanos::default(),
7099 None,
7100 )
7101 }
7102
7103 fn swap_client_with_cache(
7106 config: BlockchainExecutionClientConfig,
7107 ) -> (BlockchainExecutionClient, Rc<RefCell<Cache>>) {
7108 let cache = Rc::new(RefCell::new(Cache::default()));
7109 let pool = test_pool();
7110 cache.borrow_mut().add_pool(pool.clone()).unwrap();
7111 cache
7112 .borrow_mut()
7113 .add_order(
7114 test_market_sell_order(pool.instrument_id),
7115 None,
7116 None,
7117 false,
7118 )
7119 .unwrap();
7120 cache
7121 .borrow_mut()
7122 .add_pool_profiler(test_profiler_at_block(
7123 &pool,
7124 FIXTURE_BLOCK,
7125 FIXTURE_BLOCK_HASH,
7126 ))
7127 .unwrap();
7128 let core = ExecutionClientCore::new(
7129 TraderId::from("TRADER-001"),
7130 ClientId::from("BLOCKCHAIN-001"),
7131 *BLOCKCHAIN_VENUE,
7132 OmsType::Netting,
7133 AccountId::from("BLOCKCHAIN-001"),
7134 AccountType::Wallet,
7135 None,
7136 Rc::clone(&cache),
7137 );
7138
7139 let client = BlockchainExecutionClient::new(core, config).unwrap();
7140 (client, cache)
7141 }
7142
7143 async fn swap_client_with_database(
7145 test_name: &str,
7146 state: MockRpcState,
7147 ) -> Option<(
7148 sqlx::PgPool,
7149 String,
7150 BlockchainExecutionClient,
7151 MockRpcState,
7152 Rc<RefCell<Cache>>,
7153 )> {
7154 swap_client_with_database_config(test_name, state, test_config).await
7155 }
7156
7157 async fn swap_client_with_buy_database(
7158 test_name: &str,
7159 state: MockRpcState,
7160 ) -> Option<(
7161 sqlx::PgPool,
7162 String,
7163 BlockchainExecutionClient,
7164 MockRpcState,
7165 Rc<RefCell<Cache>>,
7166 )> {
7167 swap_client_with_database_config(test_name, state, buy_test_config).await
7168 }
7169
7170 async fn swap_client_with_database_config<F>(
7171 test_name: &str,
7172 state: MockRpcState,
7173 config: F,
7174 ) -> Option<(
7175 sqlx::PgPool,
7176 String,
7177 BlockchainExecutionClient,
7178 MockRpcState,
7179 Rc<RefCell<Cache>>,
7180 )>
7181 where
7182 F: FnOnce(String) -> BlockchainExecutionClientConfig,
7183 {
7184 let (admin_pool, pg_config) = connect_test_postgres(test_name).await?;
7185 let schema = format!("{test_name}_{}", std::process::id());
7186 setup_execution_schema(&admin_pool, &schema).await;
7187
7188 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
7189 let db_options = db_options.options([("search_path", schema.clone())]);
7190 let database = connect_test_database(db_options).await.unwrap();
7191 let addr = start_mock_rpc_server(state.clone()).await;
7192 let (mut client, cache) = swap_client_with_cache(config(format!("http://{addr}")));
7193 client.cache.database = Some(database);
7194 client
7196 .cache
7197 .ensure_execution_transaction_schema()
7198 .await
7199 .unwrap();
7200 protect_test_storage(&mut client, &schema).await;
7201 initialize_test_verification_ledger(&client).await;
7202 client.signer = Some(Arc::new(
7203 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7204 ));
7205 client.core.set_connected();
7206
7207 Some((admin_pool, schema, client, state, cache))
7208 }
7209
7210 async fn initialize_test_verification_ledger(client: &BlockchainExecutionClient) {
7211 initialize_test_verification_ledger_with_headers(
7212 client,
7213 &[ExecutionVerifiedHeader {
7214 number: FIXTURE_BLOCK,
7215 hash: FIXTURE_BLOCK_HASH.to_string(),
7216 parent_hash: "0x0000000000000000000000000000000000000000000000000000000000000001"
7217 .to_string(),
7218 timestamp: FIXTURE_BLOCK_TIMESTAMP,
7219 base_fee_per_gas: Some(100_000_000),
7220 }],
7221 )
7222 .await;
7223 }
7224
7225 async fn initialize_test_verification_ledger_with_headers(
7226 client: &BlockchainExecutionClient,
7227 finalized_headers: &[ExecutionVerifiedHeader],
7228 ) {
7229 ensure_test_verification_ledger(client, finalized_headers, 7, 7)
7230 .await
7231 .unwrap();
7232 }
7233
7234 async fn ensure_test_verification_ledger(
7235 client: &BlockchainExecutionClient,
7236 finalized_headers: &[ExecutionVerifiedHeader],
7237 next_canonical_nonce: u64,
7238 observed_canonical_nonce: u64,
7239 ) -> anyhow::Result<()> {
7240 let verification = client.config.verification.as_ref().unwrap();
7241 let provider_ids = vec![
7242 "authoritative".to_string(),
7243 "verifier-a".to_string(),
7244 "verifier-b".to_string(),
7245 ];
7246 let operator_ids = vec![
7247 "operator-a".to_string(),
7248 "operator-b".to_string(),
7249 "operator-c".to_string(),
7250 ];
7251 let failure_domain_ids = vec![
7252 "domain-a".to_string(),
7253 "domain-b".to_string(),
7254 "domain-c".to_string(),
7255 ];
7256 let decisions = [ExecutionVerificationDecision {
7257 read_class: "numbered_block",
7258 height_start: Some(FIXTURE_BLOCK),
7259 height_end: Some(FIXTURE_BLOCK),
7260 normalized_value_digest: B256::ZERO.to_string(),
7261 }];
7262 client
7263 .cache
7264 .database
7265 .as_ref()
7266 .unwrap()
7267 .ensure_execution_verification_schema(&ExecutionVerificationBootstrap {
7268 chain_id: 42_161,
7269 wallet_address: WALLET,
7270 manifest_version: &verification.manifest_version,
7271 manifest_digest: &verification.manifest_digest,
7272 checkpoint_number: FIXTURE_BLOCK,
7273 checkpoint_hash: FIXTURE_BLOCK_HASH,
7274 checkpoint_parent_hash:
7275 "0x0000000000000000000000000000000000000000000000000000000000000001",
7276 checkpoint_timestamp: FIXTURE_BLOCK_TIMESTAMP,
7277 checkpoint_base_fee_per_gas: Some(100_000_000),
7278 finalized_headers,
7279 next_canonical_nonce,
7280 observed_canonical_nonce,
7281 provider_ids: &provider_ids,
7282 operator_ids: &operator_ids,
7283 failure_domain_ids: &failure_domain_ids,
7284 decisions: &decisions,
7285 migration: None,
7286 })
7287 .await
7288 }
7289
7290 async fn initialize_test_verification_migration(
7291 client: &BlockchainExecutionClient,
7292 finalized_headers: &[ExecutionVerifiedHeader],
7293 next_canonical_nonce: u64,
7294 decisions: &[ExecutionVerificationDecision],
7295 migration: &ExecutionVerificationMigration,
7296 ) {
7297 let verification = client.config.verification.as_ref().unwrap();
7298 let provider_ids = vec![
7299 "authoritative".to_string(),
7300 "verifier-a".to_string(),
7301 "verifier-b".to_string(),
7302 ];
7303 let operator_ids = vec![
7304 "operator-a".to_string(),
7305 "operator-b".to_string(),
7306 "operator-c".to_string(),
7307 ];
7308 let failure_domain_ids = vec![
7309 "domain-a".to_string(),
7310 "domain-b".to_string(),
7311 "domain-c".to_string(),
7312 ];
7313 client
7314 .cache
7315 .database
7316 .as_ref()
7317 .unwrap()
7318 .ensure_execution_verification_schema(&ExecutionVerificationBootstrap {
7319 chain_id: 42_161,
7320 wallet_address: WALLET,
7321 manifest_version: &verification.manifest_version,
7322 manifest_digest: &verification.manifest_digest,
7323 checkpoint_number: FIXTURE_BLOCK,
7324 checkpoint_hash: FIXTURE_BLOCK_HASH,
7325 checkpoint_parent_hash:
7326 "0x0000000000000000000000000000000000000000000000000000000000000001",
7327 checkpoint_timestamp: FIXTURE_BLOCK_TIMESTAMP,
7328 checkpoint_base_fee_per_gas: Some(100_000_000),
7329 finalized_headers,
7330 next_canonical_nonce,
7331 observed_canonical_nonce: next_canonical_nonce,
7332 provider_ids: &provider_ids,
7333 operator_ids: &operator_ids,
7334 failure_domain_ids: &failure_domain_ids,
7335 decisions,
7336 migration: Some(migration),
7337 })
7338 .await
7339 .unwrap();
7340 }
7341
7342 async fn swap_rpc_state() -> MockRpcState {
7343 let min_amount_out = expected_min_amount_out(50);
7344 swap_rpc_state_with_min_amount_out(min_amount_out).await
7345 }
7346
7347 async fn swap_rpc_state_with_min_amount_out(min_amount_out: U256) -> MockRpcState {
7348 let (tx_hash, _) = expected_swap_tx(min_amount_out).await;
7349 finalized_swap_rpc_state(tx_hash, min_amount_out)
7350 }
7351
7352 async fn swap_rpc_state_for_mismatch() -> MockRpcState {
7353 swap_rpc_state()
7354 .await
7355 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION)
7356 }
7357
7358 fn awaiting_in_flight(client: &BlockchainExecutionClient) -> InFlightTransaction {
7359 let slot = *client.in_flight.lock();
7360 let Some(InFlightSlot::AwaitingFinality(in_flight)) = slot else {
7361 panic!("expected an awaiting-finality transaction, was {slot:?}");
7362 };
7363 in_flight
7364 }
7365
7366 fn recovering_in_flight(client: &BlockchainExecutionClient) -> RecoveryTransaction {
7367 let slot = *client.in_flight.lock();
7368 let Some(InFlightSlot::Recovering(recovery)) = slot else {
7369 panic!("expected a recovery transaction, was {slot:?}");
7370 };
7371 recovery
7372 }
7373
7374 async fn execution_intent_markers(
7375 admin_pool: &sqlx::PgPool,
7376 schema: &str,
7377 ) -> Vec<(String, String, bool, bool)> {
7378 sqlx::query_as(sqlx::AssertSqlSafe(format!(
7379 "SELECT purpose, status, terminal_emitted, active \
7380 FROM {schema}.execution_intent ORDER BY id"
7381 )))
7382 .fetch_all(admin_pool)
7383 .await
7384 .unwrap()
7385 }
7386
7387 #[allow(unsafe_code)] fn payload_test_keys(
7389 active: [u8; 32],
7390 retired: Vec<[u8; 32]>,
7391 deployment_id: &str,
7392 ) -> PayloadKeySet {
7393 static NEXT_ENV_ID: AtomicU64 = AtomicU64::new(0);
7394
7395 let env_id = NEXT_ENV_ID.fetch_add(1, Ordering::Relaxed);
7396 let active_env = format!("BLOCKCHAIN_TEST_PAYLOAD_KEY_{env_id}_ACTIVE");
7397 let retired_envs = retired
7398 .iter()
7399 .enumerate()
7400 .map(|(index, _)| format!("BLOCKCHAIN_TEST_PAYLOAD_KEY_{env_id}_RETIRED_{index}"))
7401 .collect::<Vec<_>>();
7402 unsafe { std::env::set_var(&active_env, hex::encode(active)) };
7404
7405 for (env, key) in retired_envs.iter().zip(retired) {
7406 unsafe { std::env::set_var(env, hex::encode(key)) };
7408 }
7409
7410 let keys = PayloadKeySet::load(Some(&active_env), &retired_envs, Some(deployment_id))
7411 .unwrap()
7412 .unwrap();
7413
7414 unsafe { std::env::remove_var(active_env) };
7416 for env in retired_envs {
7417 unsafe { std::env::remove_var(env) };
7419 }
7420 keys
7421 }
7422
7423 #[allow(unsafe_code)] fn set_test_payload_key(
7425 client: &mut BlockchainExecutionClient,
7426 key: [u8; 32],
7427 deployment_id: &str,
7428 ) -> PayloadKeySet {
7429 static NEXT_ENV_ID: AtomicU64 = AtomicU64::new(0);
7430
7431 let env_id = NEXT_ENV_ID.fetch_add(1, Ordering::Relaxed);
7432 let active_env = format!("BLOCKCHAIN_TEST_EXECUTION_KEY_{env_id}");
7433 unsafe { std::env::set_var(&active_env, hex::encode(key)) };
7435 client.config.payload_key_env = Some(active_env);
7436 client.config.payload_deployment_id = Some(deployment_id.to_string());
7437 client.load_payload_keys().unwrap().unwrap()
7438 }
7439
7440 async fn protect_test_storage(client: &mut BlockchainExecutionClient, deployment_id: &str) {
7441 let keys = set_test_payload_key(client, [0xa5; 32], deployment_id);
7442 client
7443 .cache
7444 .database
7445 .as_ref()
7446 .unwrap()
7447 .ensure_execution_payload_storage(&keys)
7448 .await
7449 .unwrap();
7450 client.payload_keys = Some(Arc::new(keys));
7451 }
7452
7453 async fn reserve_test_wrap_intent(database: &BlockchainCacheDatabase) -> ExecutionIntentRow {
7454 reserve_test_wrap_intent_for_wallet(database, WALLET).await
7455 }
7456
7457 async fn reserve_test_wrap_intent_for_wallet(
7458 database: &BlockchainCacheDatabase,
7459 wallet: &str,
7460 ) -> ExecutionIntentRow {
7461 database
7462 .reserve_execution_intent(&ExecutionIntentInsert {
7463 chain_id: 42161,
7464 wallet_address: wallet.to_string(),
7465 purpose: "wrap".to_string(),
7466 client_order_id: None,
7467 trader_id: None,
7468 strategy_id: None,
7469 account_id: None,
7470 instrument_id: None,
7471 pool_address: None,
7472 transaction_to: WETH_ADDRESS.to_string(),
7473 transaction_input: "0xd0e30db0".to_string(),
7474 transaction_value: "1".to_string(),
7475 amount_in: None,
7476 created_block: FIXTURE_BLOCK,
7477 })
7478 .await
7479 .unwrap()
7480 }
7481
7482 async fn persist_test_wrap_broadcast(
7483 database: &BlockchainCacheDatabase,
7484 keys: Option<&PayloadKeySet>,
7485 ) -> (ExecutionIntentRow, B256, Vec<u8>) {
7486 let intent = reserve_test_wrap_intent(database).await;
7487 database
7488 .assign_execution_intent_nonce(intent.id, 7)
7489 .await
7490 .unwrap();
7491 let intent = database.get_execution_intent(intent.id).await.unwrap();
7492 let transaction = build_eip1559_transaction(
7493 42161,
7494 7,
7495 78_000,
7496 130_000_000,
7497 10_000_000,
7498 WETH_ADDRESS,
7499 U256::from(1u64),
7500 Bytes::from(nautilus_core::hex::decode("d0e30db0").unwrap()),
7501 );
7502 let (tx_hash, raw_tx) = sign_eip1559_transaction(
7503 transaction,
7504 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7505 )
7506 .await
7507 .unwrap();
7508 persist_test_payload(database, keys, &intent, tx_hash, &raw_tx).await;
7509 database
7510 .record_execution_status(
7511 intent.id,
7512 &tx_hash.to_string(),
7513 TransactionStatus::Broadcast,
7514 None,
7515 None,
7516 None,
7517 None,
7518 None,
7519 )
7520 .await
7521 .unwrap();
7522 (intent, tx_hash, raw_tx)
7523 }
7524
7525 async fn reserve_test_swap_intent(database: &BlockchainCacheDatabase) -> ExecutionIntentRow {
7526 let pool = test_pool();
7527 let order = test_market_sell_order(pool.instrument_id);
7528 let calldata = expected_swap_calldata(expected_min_amount_out(50));
7529
7530 database
7531 .reserve_execution_intent(&ExecutionIntentInsert {
7532 chain_id: 42161,
7533 wallet_address: WALLET.to_string(),
7534 purpose: "swap".to_string(),
7535 client_order_id: Some(order.client_order_id().to_string()),
7536 trader_id: Some(order.trader_id().to_string()),
7537 strategy_id: Some(order.strategy_id().to_string()),
7538 account_id: Some("BLOCKCHAIN-001".to_string()),
7539 instrument_id: Some(pool.instrument_id.to_string()),
7540 pool_address: Some(pool.address.to_string()),
7541 transaction_to: ROUTER_ADDRESS.to_string(),
7542 transaction_input: hex::encode_prefixed(&calldata),
7543 transaction_value: U256::ZERO.to_string(),
7544 amount_in: Some("1000000000000000".to_string()),
7545 created_block: FIXTURE_BLOCK,
7546 })
7547 .await
7548 .unwrap()
7549 }
7550
7551 async fn persist_invalid_test_swap(
7552 database: &BlockchainCacheDatabase,
7553 keys: Option<&PayloadKeySet>,
7554 ) -> (ExecutionIntentRow, B256) {
7555 let intent = reserve_test_swap_intent(database).await;
7556 database
7557 .assign_execution_intent_nonce(intent.id, 7)
7558 .await
7559 .unwrap();
7560 let intent = database.get_execution_intent(intent.id).await.unwrap();
7561 let (tx_hash, raw_transaction) = expected_swap_tx(expected_min_amount_out(50)).await;
7562 let mut raw_transaction = hex::decode(raw_transaction.strip_prefix("0x").unwrap()).unwrap();
7563 raw_transaction.push(0xff);
7564 persist_test_payload(database, keys, &intent, tx_hash, &raw_transaction).await;
7565 database
7566 .record_execution_status(
7567 intent.id,
7568 &tx_hash.to_string(),
7569 TransactionStatus::Broadcast,
7570 None,
7571 None,
7572 None,
7573 None,
7574 None,
7575 )
7576 .await
7577 .unwrap();
7578
7579 (intent, tx_hash)
7580 }
7581
7582 async fn persist_test_swap_broadcast(
7583 database: &BlockchainCacheDatabase,
7584 keys: Option<&PayloadKeySet>,
7585 ) -> (ExecutionIntentRow, B256, Vec<u8>) {
7586 let intent = reserve_test_swap_intent(database).await;
7587 database
7588 .assign_execution_intent_nonce(intent.id, 7)
7589 .await
7590 .unwrap();
7591 let intent = database.get_execution_intent(intent.id).await.unwrap();
7592 let (tx_hash, raw_transaction) = expected_swap_tx(expected_min_amount_out(50)).await;
7593 let raw_transaction = hex::decode(raw_transaction.strip_prefix("0x").unwrap()).unwrap();
7594 persist_test_payload(database, keys, &intent, tx_hash, &raw_transaction).await;
7595 database
7596 .record_execution_status(
7597 intent.id,
7598 &tx_hash.to_string(),
7599 TransactionStatus::Broadcast,
7600 None,
7601 None,
7602 None,
7603 None,
7604 None,
7605 )
7606 .await
7607 .unwrap();
7608 (intent, tx_hash, raw_transaction)
7609 }
7610
7611 async fn persist_test_payload(
7612 database: &BlockchainCacheDatabase,
7613 keys: Option<&PayloadKeySet>,
7614 intent: &ExecutionIntentRow,
7615 tx_hash: B256,
7616 raw_transaction: &[u8],
7617 ) {
7618 if let Some(keys) = keys {
7619 database.reserve_execution_payload_seal(keys).await.unwrap();
7620 let transaction_hash = tx_hash.to_string();
7621 let context =
7622 payload_context_identity(intent, &transaction_hash, 42_161, keys.deployment_id())
7623 .unwrap();
7624 let envelope = keys.seal(raw_transaction, &context).unwrap();
7625 database
7626 .add_execution_transaction_envelope(intent.id, 42_161, &transaction_hash, &envelope)
7627 .await
7628 .unwrap();
7629 } else {
7630 database
7631 .add_execution_transaction_hash(
7632 intent.id,
7633 42_161,
7634 &tx_hash.to_string(),
7635 raw_transaction,
7636 )
7637 .await
7638 .unwrap();
7639 }
7640 }
7641
7642 async fn later_reconnect(
7643 previous: BlockchainExecutionClient,
7644 http_rpc_url: String,
7645 ) -> anyhow::Error {
7646 let database = previous.cache.database.as_ref().unwrap().clone();
7647 let payload_keys = previous.payload_keys.clone();
7648 drop(previous);
7649 let mut next = test_client(http_rpc_url);
7650 next.cache.database = Some(database);
7651 next.payload_keys = payload_keys;
7652 next.signer = Some(Arc::new(
7653 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7654 ));
7655 next.reconcile_unresolved_execution().await.unwrap_err()
7656 }
7657
7658 fn start_with_events(
7659 client: &mut BlockchainExecutionClient,
7660 ) -> tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent> {
7661 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
7662 replace_exec_event_sender(sender);
7663 client.start().unwrap();
7664 receiver
7665 }
7666
7667 async fn await_pending_tasks(client: &BlockchainExecutionClient) {
7668 tokio::time::timeout(TEST_TIMEOUT, async {
7669 while !client.pending_tasks.all_finished() {
7670 tokio::time::sleep(Duration::from_millis(1)).await;
7671 }
7672 })
7673 .await
7674 .unwrap();
7675 }
7676
7677 fn collect_order_events(
7678 receiver: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
7679 ) -> Vec<OrderEventAny> {
7680 let mut events = Vec::new();
7681
7682 while let Ok(event) = receiver.try_recv() {
7683 if let ExecutionEvent::Order(order_event) = event {
7684 events.push(order_event);
7685 }
7686 }
7687 events
7688 }
7689
7690 fn assert_swap_quarantined_without_terminal_event(events: &[OrderEventAny]) {
7691 assert_eq!(events.len(), 1, "was: {events:?}");
7692 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
7693 }
7694
7695 fn assert_swap_submitted_and_filled(events: &[OrderEventAny]) {
7696 assert_eq!(events.len(), 2, "was: {events:?}");
7697 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
7698 assert!(matches!(&events[1], OrderEventAny::Filled(_)));
7699 }
7700
7701 fn expected_swap_calldata(min_amount_out: U256) -> Vec<u8> {
7702 UniswapV3SwapRouter::exactInputSingleCall {
7703 params: UniswapV3SwapRouter::ExactInputSingleParams {
7704 tokenIn: WETH_ADDRESS,
7705 tokenOut: address!("af88d065e77c8cC2239327C5EDb3A432268e5831"),
7706 fee: U24::try_from(500u32).unwrap(),
7707 recipient: address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
7708 deadline: U256::from(FIXTURE_BLOCK_TIMESTAMP + 300),
7709 amountIn: U256::from(1_000_000_000_000_000u64),
7710 amountOutMinimum: min_amount_out,
7711 sqrtPriceLimitX96: U160::ZERO,
7712 },
7713 }
7714 .abi_encode()
7715 }
7716
7717 fn expected_min_amount_out(slippage_bps: u32) -> U256 {
7718 expected_min_amount_out_for(&test_pool(), true, slippage_bps)
7719 }
7720
7721 fn expected_min_amount_out_for(pool: &Pool, zero_for_one: bool, slippage_bps: u32) -> U256 {
7722 let profiler = test_profiler(pool, FIXTURE_BLOCK);
7723 let quote = profiler
7724 .swap_exact_in(U256::from(1_000_000_000_000_000u64), zero_for_one, None)
7725 .unwrap();
7726 let quoted = exact_output_amount("e, zero_for_one).unwrap();
7727 derive_min_amount_out(quoted, slippage_bps).unwrap()
7728 }
7729
7730 fn expected_sell_quote_amount() -> U256 {
7731 let profiler = test_profiler(&test_pool(), FIXTURE_BLOCK);
7732 let quote = profiler
7733 .swap_exact_in(U256::from(1_000_000_000_000_000u64), true, None)
7734 .unwrap();
7735 exact_output_amount("e, true).unwrap()
7736 }
7737
7738 fn quote_response(amount: U256) -> String {
7739 let result = (amount, U160::from(1u128 << 96), 0u32, U256::from(50_000u64)).abi_encode();
7740 serde_json::json!({
7741 "jsonrpc": "2.0",
7742 "id": 1,
7743 "result": hex::encode_prefixed(result),
7744 })
7745 .to_string()
7746 }
7747
7748 async fn expected_swap_tx(min_amount_out: U256) -> (B256, String) {
7749 let expected_tx = build_eip1559_transaction(
7752 42161,
7753 7,
7754 78_000,
7755 130_000_000,
7756 10_000_000,
7757 ROUTER_ADDRESS,
7758 U256::ZERO,
7759 Bytes::from(expected_swap_calldata(min_amount_out)),
7760 );
7761 sign_eip1559_transaction(
7762 expected_tx,
7763 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7764 )
7765 .await
7766 .map(|(hash, raw)| (hash, nautilus_core::hex::encode_prefixed(&raw)))
7767 .unwrap()
7768 }
7769
7770 fn expected_buy_base_amount() -> U256 {
7771 U256::from(1_000_000_000_000_000u64)
7772 }
7773
7774 fn expected_buy_amount_in() -> U256 {
7775 let profiler = test_profiler(&test_pool(), FIXTURE_BLOCK);
7776 profiler
7777 .swap_exact_out(expected_buy_base_amount(), false, None)
7778 .unwrap()
7779 .get_input_amount()
7780 }
7781
7782 fn expected_buy_min_amount_out(slippage_bps: u32) -> U256 {
7783 derive_min_amount_out(expected_buy_base_amount(), slippage_bps).unwrap()
7784 }
7785
7786 fn expected_buy_swap_calldata(min_amount_out: U256, amount_in: U256) -> Vec<u8> {
7787 UniswapV3SwapRouter::exactInputSingleCall {
7788 params: UniswapV3SwapRouter::ExactInputSingleParams {
7789 tokenIn: USDC_ADDRESS,
7790 tokenOut: WETH_ADDRESS,
7791 fee: U24::try_from(500u32).unwrap(),
7792 recipient: address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
7793 deadline: U256::from(FIXTURE_BLOCK_TIMESTAMP + 300),
7794 amountIn: amount_in,
7795 amountOutMinimum: min_amount_out,
7796 sqrtPriceLimitX96: U160::ZERO,
7797 },
7798 }
7799 .abi_encode()
7800 }
7801
7802 async fn expected_buy_swap_tx(min_amount_out: U256, amount_in: U256) -> (B256, String) {
7803 let expected_tx = build_eip1559_transaction(
7804 42161,
7805 7,
7806 78_000,
7807 130_000_000,
7808 10_000_000,
7809 ROUTER_ADDRESS,
7810 U256::ZERO,
7811 Bytes::from(expected_buy_swap_calldata(min_amount_out, amount_in)),
7812 );
7813 sign_eip1559_transaction(
7814 expected_tx,
7815 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7816 )
7817 .await
7818 .map(|(hash, raw)| (hash, nautilus_core::hex::encode_prefixed(&raw)))
7819 .unwrap()
7820 }
7821
7822 fn finalized_buy_swap_receipt(tx_hash: B256, amount_in: U256) -> String {
7823 finalized_buy_swap_receipt_with_base_out(tx_hash, amount_in, expected_buy_base_amount())
7824 }
7825
7826 fn finalized_buy_swap_receipt_with_base_out(
7827 tx_hash: B256,
7828 amount_in: U256,
7829 base_out: U256,
7830 ) -> String {
7831 let data = (
7832 -I256::try_from(u128::try_from(base_out).unwrap()).unwrap(),
7833 I256::try_from(u128::try_from(amount_in).unwrap()).unwrap(),
7834 U160::from(1_u128 << 96),
7835 TEST_LIQUIDITY,
7836 I24::try_from(0).unwrap(),
7837 )
7838 .abi_encode();
7839 serde_json::json!({
7840 "jsonrpc": "2.0",
7841 "id": 1,
7842 "result": {
7843 "transactionHash": tx_hash.to_string(),
7844 "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7845 "blockNumber": "0x1cf0d41",
7846 "transactionIndex": "0x2",
7847 "gasUsed": "0xc3c0",
7848 "effectiveGasPrice": "0x5f5e100",
7849 "status": "0x1",
7850 "logs": [{
7851 "removed": false,
7852 "logIndex": "0x6",
7853 "transactionIndex": "0x2",
7854 "transactionHash": tx_hash.to_string(),
7855 "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7856 "blockNumber": "0x1cf0d41",
7857 "address": test_pool().address.to_string(),
7858 "data": hex::encode_prefixed(data),
7859 "topics": [
7860 "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67",
7861 "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266",
7862 "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266"
7863 ]
7864 }]
7865 }
7866 })
7867 .to_string()
7868 }
7869
7870 fn finalized_buy_swap_block(tx_hash: B256, min_amount_out: U256, amount_in: U256) -> String {
7871 serde_json::json!({
7872 "jsonrpc": "2.0",
7873 "id": 1,
7874 "result": {
7875 "number": "0x1cf0d41",
7876 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7877 "parentHash": FIXTURE_BLOCK_HASH,
7878 "timestamp": "0x69044a21",
7879 "baseFeePerGas": "0x5f5e100",
7880 "transactions": [{
7881 "hash": tx_hash.to_string(),
7882 "from": WALLET,
7883 "nonce": "0x7",
7884 "chainId": "0xa4b1",
7885 "type": "0x2",
7886 "to": ROUTER,
7887 "input": hex::encode_prefixed(expected_buy_swap_calldata(min_amount_out, amount_in)),
7888 "value": "0x0",
7889 "gas": "0x130b0",
7890 "maxFeePerGas": "0x7bfa480",
7891 "maxPriorityFeePerGas": "0x989680"
7892 }]
7893 }
7894 })
7895 .to_string()
7896 }
7897
7898 fn finalized_buy_swap_rpc_state(
7899 tx_hash: B256,
7900 min_amount_out: U256,
7901 amount_in: U256,
7902 ) -> MockRpcState {
7903 let receipt = finalized_buy_swap_receipt(tx_hash, amount_in);
7904 let block = finalized_buy_swap_block(tx_hash, min_amount_out, amount_in);
7905 with_finalized_identity(
7906 signing_rpc_state()
7907 .with_response("eth_getTransactionReceipt", &receipt)
7908 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
7909 .with_send_raw_transaction_echo()
7910 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE)
7911 .with_call_response(ALLOWANCE_SELECTOR, CALL_ALLOWANCE),
7912 &block,
7913 &receipt,
7914 )
7915 }
7916
7917 fn finalized_swap_receipt(tx_hash: B256) -> String {
7918 let data = (
7919 I256::try_from(1_000_000_000_000_000_i128).unwrap(),
7920 I256::try_from(-1_000_000_i128).unwrap(),
7921 U160::from(1_u128 << 96),
7922 TEST_LIQUIDITY,
7923 I24::try_from(0).unwrap(),
7924 )
7925 .abi_encode();
7926 serde_json::json!({
7927 "jsonrpc": "2.0",
7928 "id": 1,
7929 "result": {
7930 "transactionHash": tx_hash.to_string(),
7931 "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7932 "blockNumber": "0x1cf0d41",
7933 "transactionIndex": "0x2",
7934 "gasUsed": "0xc3c0",
7935 "effectiveGasPrice": "0x5f5e100",
7936 "status": "0x1",
7937 "logs": [{
7938 "removed": false,
7939 "logIndex": "0x6",
7940 "transactionIndex": "0x2",
7941 "transactionHash": tx_hash.to_string(),
7942 "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7943 "blockNumber": "0x1cf0d41",
7944 "address": test_pool().address.to_string(),
7945 "data": hex::encode_prefixed(data),
7946 "topics": [
7947 "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67",
7948 "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266",
7949 "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266"
7950 ]
7951 }]
7952 }
7953 })
7954 .to_string()
7955 }
7956
7957 fn finalized_swap_receipt_with_unrelated_swap(tx_hash: B256) -> String {
7958 let mut receipt: serde_json::Value =
7959 serde_json::from_str(&finalized_swap_receipt(tx_hash)).unwrap();
7960 let logs = receipt["result"]["logs"].as_array_mut().unwrap();
7961 let mut unrelated = logs[0].clone();
7962 unrelated["address"] = serde_json::json!(ROUTER);
7963 unrelated["logIndex"] = serde_json::json!("0x7");
7964 logs.push(unrelated);
7965 receipt.to_string()
7966 }
7967
7968 fn finalized_swap_block(tx_hash: B256, min_amount_out: U256) -> String {
7969 serde_json::json!({
7970 "jsonrpc": "2.0",
7971 "id": 1,
7972 "result": {
7973 "number": "0x1cf0d41",
7974 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7975 "parentHash": FIXTURE_BLOCK_HASH,
7976 "timestamp": "0x69044a21",
7977 "baseFeePerGas": "0x5f5e100",
7978 "transactions": [{
7979 "hash": tx_hash.to_string(),
7980 "from": WALLET,
7981 "nonce": "0x7",
7982 "chainId": "0xa4b1",
7983 "type": "0x2",
7984 "to": ROUTER,
7985 "input": hex::encode_prefixed(expected_swap_calldata(min_amount_out)),
7986 "value": "0x0",
7987 "gas": "0x130b0",
7988 "maxFeePerGas": "0x7bfa480",
7989 "maxPriorityFeePerGas": "0x989680"
7990 }]
7991 }
7992 })
7993 .to_string()
7994 }
7995
7996 fn finalized_swap_rpc_state(tx_hash: B256, min_amount_out: U256) -> MockRpcState {
7997 let receipt = finalized_swap_receipt(tx_hash);
7998 let block = finalized_swap_block(tx_hash, min_amount_out);
7999 with_finalized_identity(
8000 signing_rpc_state()
8001 .with_response("eth_getTransactionReceipt", &receipt)
8002 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
8003 .with_send_raw_transaction_echo()
8004 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE)
8005 .with_call_response(ALLOWANCE_SELECTOR, CALL_ALLOWANCE),
8006 &block,
8007 &receipt,
8008 )
8009 }
8010
8011 fn with_finalized_identity(
8012 state: MockRpcState,
8013 block_response: &str,
8014 receipt_response: &str,
8015 ) -> MockRpcState {
8016 let block: serde_json::Value = serde_json::from_str(block_response).unwrap();
8017 let receipt: serde_json::Value = serde_json::from_str(receipt_response).unwrap();
8018 let transaction = block["result"]["transactions"][0].clone();
8019 let transaction_response = serde_json::json!({
8020 "jsonrpc": "2.0",
8021 "id": 1,
8022 "result": transaction,
8023 })
8024 .to_string();
8025 let success = receipt["result"]["status"] == "0x1";
8026 let mut trace = serde_json::json!({
8027 "type": "CALL",
8028 "from": transaction["from"],
8029 "to": transaction["to"],
8030 "value": transaction["value"],
8031 "gas": transaction["gas"],
8032 "gasUsed": receipt["result"]["gasUsed"],
8033 "input": transaction["input"],
8034 "output": "0x",
8035 "calls": [],
8036 });
8037
8038 if !success {
8039 trace["error"] = serde_json::json!("execution reverted");
8040 }
8041 let trace_response = serde_json::json!({
8042 "jsonrpc": "2.0",
8043 "id": 1,
8044 "result": trace,
8045 })
8046 .to_string();
8047 state
8048 .with_response("eth_getTransactionByHash", &transaction_response)
8049 .with_response("debug_traceTransaction", &trace_response)
8050 }
8051
8052 fn receipt_with_transaction_hash(receipt_response: &str, tx_hash: B256) -> String {
8053 let mut receipt: serde_json::Value = serde_json::from_str(receipt_response).unwrap();
8054 receipt["result"]["transactionHash"] = serde_json::json!(tx_hash.to_string());
8055 receipt.to_string()
8056 }
8057
8058 fn replacement_head_block(tx_hash: B256) -> String {
8059 serde_json::json!({
8060 "jsonrpc": "2.0",
8061 "id": 1,
8062 "result": {
8063 "number": "0x1cf0d40",
8064 "hash": "0x1111111111111111111111111111111111111111111111111111111111111111",
8065 "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000001",
8066 "timestamp": "0x69044a20",
8067 "baseFeePerGas": "0x5f5e100",
8068 "transactions": [{
8069 "hash": "0x3333333333333333333333333333333333333333333333333333333333333333",
8070 "from": "0x0000000000000000000000000000000000000001",
8071 "nonce": "0x1",
8072 "type": "0x0",
8073 "to": WETH,
8074 "input": "0x",
8075 "value": "0x0",
8076 "gas": "0x5208"
8077 }, {
8078 "hash": tx_hash.to_string(),
8079 "from": WALLET,
8080 "nonce": "0x7",
8081 "chainId": "0xa4b1",
8082 "type": "0x2",
8083 "to": WETH,
8084 "input": "0xd0e30db0",
8085 "value": "0x38d7ea4c68000",
8086 "gas": "0x130b0",
8087 "maxFeePerGas": "0x7bfa480",
8088 "maxPriorityFeePerGas": "0x989680"
8089 }]
8090 }
8091 })
8092 .to_string()
8093 }
8094
8095 fn finalized_wrap_block(tx_hash: B256) -> String {
8096 serde_json::json!({
8097 "jsonrpc": "2.0",
8098 "id": 1,
8099 "result": {
8100 "number": "0x1cf0d41",
8101 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
8102 "parentHash": FIXTURE_BLOCK_HASH,
8103 "timestamp": "0x69044a21",
8104 "baseFeePerGas": "0x5f5e100",
8105 "transactions": [{
8106 "hash": tx_hash.to_string(),
8107 "from": WALLET,
8108 "nonce": "0x7",
8109 "chainId": "0xa4b1",
8110 "type": "0x2",
8111 "to": WETH,
8112 "input": "0xd0e30db0",
8113 "value": "0x38d7ea4c68000",
8114 "gas": "0x130b0",
8115 "maxFeePerGas": "0x7bfa480",
8116 "maxPriorityFeePerGas": "0x989680"
8117 }]
8118 }
8119 })
8120 .to_string()
8121 }
8122
8123 fn finalized_approve_block(tx_hash: B256, amount: U256) -> String {
8124 let calldata = ERC20::approveCall {
8125 spender: ROUTER_ADDRESS,
8126 amount,
8127 }
8128 .abi_encode();
8129 serde_json::json!({
8130 "jsonrpc": "2.0",
8131 "id": 1,
8132 "result": {
8133 "number": "0x1cf0d41",
8134 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
8135 "parentHash": FIXTURE_BLOCK_HASH,
8136 "timestamp": "0x69044a21",
8137 "baseFeePerGas": "0x5f5e100",
8138 "transactions": [{
8139 "hash": tx_hash.to_string(),
8140 "from": WALLET,
8141 "nonce": "0x7",
8142 "chainId": "0xa4b1",
8143 "type": "0x2",
8144 "to": WETH,
8145 "input": hex::encode_prefixed(calldata),
8146 "value": "0x0",
8147 "gas": "0x130b0",
8148 "maxFeePerGas": "0x7bfa480",
8149 "maxPriorityFeePerGas": "0x989680"
8150 }]
8151 }
8152 })
8153 .to_string()
8154 }
8155
8156 fn fixture_sell_plan() -> SwapPlan {
8157 let pool = test_pool();
8158 let order = test_market_sell_order(pool.instrument_id);
8159 let quote_token = pool.get_quote_token();
8160 SwapPlan {
8161 order,
8162 quote_currency: Currency::new_checked(
8163 "e_token.symbol,
8164 quote_token.decimals,
8165 0,
8166 "e_token.name,
8167 CurrencyType::Crypto,
8168 )
8169 .unwrap(),
8170 instrument_id: pool.instrument_id,
8171 pool_address: pool.address,
8172 router: ROUTER_ADDRESS,
8173 factory: UNISWAP_V3.dex.factory,
8174 weth: WETH_ADDRESS,
8175 token_in: WETH_ADDRESS,
8176 token_out: USDC_ADDRESS,
8177 fee: U24::try_from(500u32).unwrap(),
8178 amount_in: U256::from(1_000_000_000_000_000u64),
8179 min_amount_out: expected_min_amount_out(50),
8180 slippage_bps: 50,
8181 quote_spend_ceiling: None,
8182 profiler_position: Some(profiler_event_position()),
8183 pool,
8184 }
8185 }
8186
8187 fn fixture_block_response(number: u64, hash: B256) -> String {
8188 let parent_hash = if number == FIXTURE_BLOCK {
8189 B256::from_str("0x0000000000000000000000000000000000000000000000000000000000000001")
8190 .unwrap()
8191 } else {
8192 B256::from_str(FIXTURE_BLOCK_HASH).unwrap()
8193 };
8194 serde_json::json!({
8195 "jsonrpc": "2.0",
8196 "id": 1,
8197 "result": {
8198 "number": format!("0x{number:x}"),
8199 "hash": hash.to_string(),
8200 "parentHash": parent_hash.to_string(),
8201 "timestamp": format!("0x{:x}", FIXTURE_BLOCK_TIMESTAMP + number - FIXTURE_BLOCK),
8202 "baseFeePerGas": "0x5f5e100",
8203 "transactions": []
8204 }
8205 })
8206 .to_string()
8207 }
8208
8209 fn profiler_event_receipt(pool_address: Address) -> String {
8210 let transaction_hash = B256::from([0x33; 32]);
8211 serde_json::json!({
8212 "jsonrpc": "2.0",
8213 "id": 1,
8214 "result": {
8215 "transactionHash": transaction_hash.to_string(),
8216 "blockHash": FIXTURE_BLOCK_HASH,
8217 "blockNumber": "0x1cf0d40",
8218 "transactionIndex": "0x2",
8219 "gasUsed": "0xc3c0",
8220 "effectiveGasPrice": "0x5f5e100",
8221 "status": "0x1",
8222 "logs": [{
8223 "removed": false,
8224 "logIndex": "0x6",
8225 "transactionIndex": "0x2",
8226 "transactionHash": transaction_hash.to_string(),
8227 "blockHash": FIXTURE_BLOCK_HASH,
8228 "blockNumber": "0x1cf0d40",
8229 "address": pool_address.to_string(),
8230 "data": "0x",
8231 "topics": [test_pool().dex.swap_created_event.as_ref()]
8232 }]
8233 }
8234 })
8235 .to_string()
8236 }
8237
8238 fn profiler_event_position() -> BlockPosition {
8239 BlockPosition::new(FIXTURE_BLOCK, B256::from([0x33; 32]).to_string(), 2, 6)
8240 .with_block_hash(Some(FIXTURE_BLOCK_HASH.to_string()))
8241 }
8242
8243 #[rstest]
8244 fn quantity_to_raw_amount_scales_by_token_decimals() {
8245 assert_eq!(
8246 quantity_to_raw_amount(Quantity::from("0.001"), 18).unwrap(),
8247 U256::from(1_000_000_000_000_000u64)
8248 );
8249 assert_eq!(
8250 quantity_to_raw_amount(Quantity::from("1.5"), 18).unwrap(),
8251 U256::from(1_500_000_000_000_000_000u128)
8252 );
8253 assert_eq!(
8254 quantity_to_raw_amount(Quantity::from("12.5"), 6).unwrap(),
8255 U256::from(12_500_000u64)
8256 );
8257 }
8258
8259 #[rstest]
8260 fn quantity_to_raw_amount_uses_defi_quantity_precision() {
8261 let amount = U256::from(10_000_000_000_000_000u64);
8262 let quantity = Quantity::from_u256(amount, 18).unwrap();
8263
8264 assert_eq!(quantity_to_raw_amount(quantity, 18).unwrap(), amount);
8265 }
8266
8267 #[rstest]
8268 fn quantity_to_raw_amount_rejects_zero() {
8269 let error = quantity_to_raw_amount(Quantity::from("0.0"), 18).unwrap_err();
8270
8271 assert_eq!(error.to_string(), "Order quantity must be positive");
8272 }
8273
8274 #[rstest]
8275 fn quantity_to_raw_amount_rejects_inexact_token_units() {
8276 let error = quantity_to_raw_amount(Quantity::from("0.0000001"), 6).unwrap_err();
8277
8278 assert!(
8279 error
8280 .to_string()
8281 .contains("is not exactly representable in 6 base token decimals"),
8282 "was: {error}"
8283 );
8284 }
8285
8286 #[rstest]
8287 fn raw_amount_to_quantity_inverts_base_quantity() {
8288 let quantity = Quantity::from("0.001");
8289 let amount = quantity_to_raw_amount(quantity, 18).unwrap();
8290
8291 assert_eq!(raw_amount_to_quantity(amount, 18).unwrap(), quantity);
8292 }
8293
8294 #[rstest]
8295 fn raw_amount_to_quantity_rejects_positive_amount_truncated_to_zero() {
8296 let error = raw_amount_to_quantity(U256::from(99u64), 18).unwrap_err();
8297
8298 assert!(
8299 error
8300 .to_string()
8301 .contains("is below representable quantity precision"),
8302 "was: {error}"
8303 );
8304 }
8305
8306 #[rstest]
8307 fn fill_price_from_quote_recovers_spent_quote_after_quantity_truncation() {
8308 let last_qty = raw_amount_to_quantity(U256::from(1_000_000_403_079_044u64), 18).unwrap();
8309 let quote_amount = U256::from(1_891_348u64);
8310 let quote = Currency::new_checked("USDC", 6, 0, "USD Coin", CurrencyType::Crypto).unwrap();
8311 let last_px = fill_price_from_quote(last_qty, quote_amount, quote).unwrap();
8312
8313 assert_eq!(
8314 Money::from_decimal(last_qty.as_decimal() * last_px.as_decimal(), quote).unwrap(),
8315 Money::from_u256(quote_amount, quote).unwrap()
8316 );
8317 }
8318
8319 #[rstest]
8320 fn swap_token_pair_is_directional() {
8321 assert_eq!(
8322 swap_token_pair(OrderSide::Sell, WETH_ADDRESS, USDC_ADDRESS),
8323 (WETH_ADDRESS, USDC_ADDRESS)
8324 );
8325 assert_eq!(
8326 swap_token_pair(OrderSide::Buy, WETH_ADDRESS, USDC_ADDRESS),
8327 (USDC_ADDRESS, WETH_ADDRESS)
8328 );
8329 }
8330
8331 #[rstest]
8332 fn restore_swap_plan_buy_uses_quote_input() {
8333 let (client, cache) =
8334 swap_client_with_cache(buy_test_config("http://127.0.0.1:1".to_string()));
8335 let order = test_market_buy_order(test_pool().instrument_id);
8336 cache
8337 .borrow_mut()
8338 .add_order(order.clone(), None, None, true)
8339 .unwrap();
8340 let amount_in = U256::from(2_345_678u64);
8341 let intent = ExecutionIntentRow {
8342 id: 7,
8343 schema_version: crate::execution::transaction::EXECUTION_SCHEMA_VERSION,
8344 chain_id: 42161,
8345 wallet_address: WALLET.to_string(),
8346 nonce: Some(7),
8347 purpose: "swap".to_string(),
8348 status: "finalized".to_string(),
8349 client_order_id: Some(order.client_order_id().to_string()),
8350 trader_id: Some(order.trader_id().to_string()),
8351 strategy_id: Some(order.strategy_id().to_string()),
8352 account_id: Some("BLOCKCHAIN-001".to_string()),
8353 instrument_id: Some(order.instrument_id().to_string()),
8354 pool_address: Some(test_pool().address.to_string()),
8355 transaction_to: ROUTER.to_string(),
8356 transaction_input: "0x".to_string(),
8357 transaction_value: "0".to_string(),
8358 amount_in: Some(amount_in.to_string()),
8359 created_block: FIXTURE_BLOCK,
8360 acknowledgement_emitted: true,
8361 fill_emitted: false,
8362 terminal_emitted: false,
8363 active: true,
8364 };
8365
8366 let plan = client.restore_swap_plan(&intent).unwrap();
8367
8368 assert_eq!(plan.token_in, USDC_ADDRESS);
8369 assert_eq!(plan.token_out, WETH_ADDRESS);
8370 assert_eq!(plan.amount_in, amount_in);
8371 }
8372
8373 #[rstest]
8374 #[case(1_000_000, 50, 995_000)]
8375 #[case(1_000_000, 0, 1_000_000)]
8376 #[case(1_000_000, 200, 980_000)]
8377 #[case(10_000, 9_999, 1)]
8378 fn derive_min_amount_out_applies_slippage(
8379 #[case] quoted: u64,
8380 #[case] slippage_bps: u32,
8381 #[case] expected: u64,
8382 ) {
8383 assert_eq!(
8384 derive_min_amount_out(U256::from(quoted), slippage_bps).unwrap(),
8385 U256::from(expected)
8386 );
8387 }
8388
8389 #[rstest]
8390 fn derive_min_amount_out_rejects_zero_result() {
8391 let error = derive_min_amount_out(U256::from(9_999u64), 9_999).unwrap_err();
8392
8393 assert!(
8394 error.to_string().contains("Derived minimum output is zero"),
8395 "was: {error}"
8396 );
8397 }
8398
8399 #[rstest]
8400 fn derive_min_amount_out_rejects_full_slippage() {
8401 let error = derive_min_amount_out(U256::from(1_000_000u64), 10_000).unwrap_err();
8402
8403 assert!(
8404 error.to_string().contains("must be below 10000"),
8405 "was: {error}"
8406 );
8407 }
8408
8409 #[rstest]
8410 fn replacement_scan_range_is_bounded_and_checked() {
8411 assert_eq!(
8412 replacement_scan_range(10, 10).unwrap(),
8413 RangeInclusive::new(10, 10)
8414 );
8415 assert_eq!(
8416 replacement_scan_range(10, 10 + MAX_REPLACEMENT_SCAN_BLOCKS).unwrap(),
8417 RangeInclusive::new(10, 10 + MAX_REPLACEMENT_SCAN_BLOCKS - 1)
8418 );
8419 assert!(
8420 replacement_scan_range(11, 10)
8421 .unwrap_err()
8422 .to_string()
8423 .contains("is behind execution creation block")
8424 );
8425 assert_eq!(
8426 replacement_scan_range(u64::MAX - 1, u64::MAX).unwrap(),
8427 RangeInclusive::new(u64::MAX - 1, u64::MAX)
8428 );
8429 }
8430
8431 #[rstest]
8432 fn terminal_execution_event_ids_are_stable_and_kind_specific() {
8433 let transaction_hash = B256::from([0x42; 32]);
8434
8435 let fill = execution_event_id(transaction_hash, b"fill");
8436 let fill_retry = execution_event_id(transaction_hash, b"fill");
8437 let reverted = execution_event_id(transaction_hash, b"reverted");
8438
8439 assert_eq!(fill, fill_retry);
8440 assert_ne!(fill, reverted);
8441 }
8442
8443 #[rstest]
8444 fn prepared_transaction_debug_redacts_raw_transaction() {
8445 let prepared = PreparedTransaction {
8446 intent_id: 1,
8447 created_block: 2,
8448 nonce: 3,
8449 tx_hash: B256::ZERO,
8450 raw_tx: vec![0xde, 0xad, 0xbe, 0xef],
8451 payload_lease: None,
8452 };
8453
8454 let debug = format!("{prepared:?}");
8455
8456 assert!(debug.contains("raw_tx: \"<redacted>\""));
8457 assert!(!debug.contains("[222, 173, 190, 239]"));
8458 }
8459
8460 #[rstest]
8461 fn call_trace_rejects_unreviewed_internal_edge() {
8462 let signed = crate::execution::transaction::DecodedSignedTransaction {
8463 hash: B256::from([1; 32]),
8464 signer: Address::from_str(WALLET).unwrap(),
8465 chain_id: 42_161,
8466 nonce: 7,
8467 to: ROUTER_ADDRESS,
8468 value: U256::ZERO,
8469 input: Bytes::from(expected_swap_calldata(expected_min_amount_out(50))),
8470 gas_limit: 78_000,
8471 max_fee_per_gas: 130_000_000,
8472 max_priority_fee_per_gas: 10_000_000,
8473 };
8474 let input_digest = keccak256(&signed.input);
8475 let trace = VerifiedCallTrace {
8476 call_type: RpcCallType::Call,
8477 from: signed.signer,
8478 to: Some(signed.to),
8479 value: signed.value,
8480 input_selector: signed
8481 .input
8482 .get(..4)
8483 .map(|selector| selector.try_into().unwrap()),
8484 input_digest,
8485 success: true,
8486 calls: vec![VerifiedCallTrace {
8487 call_type: RpcCallType::Call,
8488 from: ROUTER_ADDRESS,
8489 to: Some(WETH_ADDRESS),
8490 value: U256::ZERO,
8491 input_selector: None,
8492 input_digest: B256::ZERO,
8493 success: true,
8494 calls: Vec::new(),
8495 }],
8496 };
8497 let manifest = &test_config("http://127.0.0.1:1".to_string())
8498 .verification
8499 .unwrap()
8500 .deployment_manifest;
8501
8502 let error = validate_call_trace(&trace, &signed, true, "swap_sell", manifest).unwrap_err();
8503
8504 assert!(
8505 error.to_string().contains("unreviewed call edge"),
8506 "was: {error}"
8507 );
8508 }
8509
8510 #[rstest]
8511 fn call_trace_rejects_unlisted_precompile_target() {
8512 let manifest = test_config("http://127.0.0.1:1".to_string())
8513 .verification
8514 .unwrap()
8515 .deployment_manifest;
8516 let calls = [VerifiedCallTrace {
8517 call_type: RpcCallType::Call,
8518 from: ROUTER_ADDRESS,
8519 to: Some(address!("0000000000000000000000000000000000000007")),
8520 value: U256::ZERO,
8521 input_selector: None,
8522 input_digest: B256::ZERO,
8523 success: true,
8524 calls: Vec::new(),
8525 }];
8526
8527 let error =
8528 validate_internal_calls(&calls, ROUTER_ADDRESS, "swap_sell", &manifest).unwrap_err();
8529
8530 assert_eq!(
8531 error.to_string(),
8532 format!(
8533 "Verified call trace contains an unreviewed call edge {ROUTER_ADDRESS} -> {} for swap_sell",
8534 address!("0000000000000000000000000000000000000007")
8535 )
8536 );
8537 }
8538
8539 #[rstest]
8540 fn call_trace_requires_exact_call_type() {
8541 let manifest = test_config("http://127.0.0.1:1".to_string())
8542 .verification
8543 .unwrap()
8544 .deployment_manifest;
8545 let calls = [VerifiedCallTrace {
8546 call_type: RpcCallType::Staticcall,
8547 from: ROUTER_ADDRESS,
8548 to: Some(test_pool().address),
8549 value: U256::ZERO,
8550 input_selector: None,
8551 input_digest: B256::ZERO,
8552 success: true,
8553 calls: Vec::new(),
8554 }];
8555
8556 let error =
8557 validate_internal_calls(&calls, ROUTER_ADDRESS, "swap_sell", &manifest).unwrap_err();
8558
8559 assert!(
8560 error.to_string().contains("unreviewed staticcall edge"),
8561 "was: {error}"
8562 );
8563 }
8564
8565 #[rstest]
8566 fn call_trace_requires_exact_caller() {
8567 let manifest = test_config("http://127.0.0.1:1".to_string())
8568 .verification
8569 .unwrap()
8570 .deployment_manifest;
8571 let calls = [VerifiedCallTrace {
8572 call_type: RpcCallType::Call,
8573 from: WETH_ADDRESS,
8574 to: Some(test_pool().address),
8575 value: U256::ZERO,
8576 input_selector: None,
8577 input_digest: B256::ZERO,
8578 success: true,
8579 calls: Vec::new(),
8580 }];
8581
8582 let error =
8583 validate_internal_calls(&calls, ROUTER_ADDRESS, "swap_sell", &manifest).unwrap_err();
8584
8585 assert_eq!(
8586 error.to_string(),
8587 "Verified call trace child has an invalid caller context"
8588 );
8589 }
8590
8591 #[rstest]
8592 fn call_trace_rejects_contract_creation() {
8593 let signed = crate::execution::transaction::DecodedSignedTransaction {
8594 hash: B256::from([1; 32]),
8595 signer: Address::from_str(WALLET).unwrap(),
8596 chain_id: 42_161,
8597 nonce: 7,
8598 to: ROUTER_ADDRESS,
8599 value: U256::ZERO,
8600 input: Bytes::from(expected_swap_calldata(expected_min_amount_out(50))),
8601 gas_limit: 78_000,
8602 max_fee_per_gas: 130_000_000,
8603 max_priority_fee_per_gas: 10_000_000,
8604 };
8605 let trace = VerifiedCallTrace {
8606 call_type: RpcCallType::Call,
8607 from: signed.signer,
8608 to: Some(signed.to),
8609 value: signed.value,
8610 input_selector: signed
8611 .input
8612 .get(..4)
8613 .map(|selector| selector.try_into().unwrap()),
8614 input_digest: keccak256(&signed.input),
8615 success: true,
8616 calls: vec![VerifiedCallTrace {
8617 call_type: RpcCallType::Create,
8618 from: ROUTER_ADDRESS,
8619 to: Some(address!("0000000000000000000000000000000000000007")),
8620 value: U256::ZERO,
8621 input_selector: None,
8622 input_digest: B256::ZERO,
8623 success: true,
8624 calls: Vec::new(),
8625 }],
8626 };
8627 let manifest = &test_config("http://127.0.0.1:1".to_string())
8628 .verification
8629 .unwrap()
8630 .deployment_manifest;
8631
8632 let error = validate_call_trace(&trace, &signed, true, "swap_sell", manifest).unwrap_err();
8633
8634 assert!(
8635 error
8636 .to_string()
8637 .contains("forbidden state-changing operation"),
8638 "was: {error}"
8639 );
8640 }
8641
8642 #[tokio::test]
8643 async fn swap_quote_rejects_missing_ingestion_block_hash() {
8644 let (client, state) = client_with_mock_rpc(execution_rpc_state()).await;
8645 let plan = fixture_sell_plan();
8646 let position = BlockPosition::new(
8647 FIXTURE_BLOCK,
8648 FIXTURE_BLOCK_HASH.to_string(),
8649 BLOCK_SCOPED_SNAPSHOT_INDEX,
8650 BLOCK_SCOPED_SNAPSHOT_INDEX,
8651 );
8652
8653 let error = validate_swap_quote(
8654 &position,
8655 &plan,
8656 100,
8657 &client.verification,
8658 &client
8659 .config
8660 .verification
8661 .as_ref()
8662 .unwrap()
8663 .deployment_manifest,
8664 )
8665 .await
8666 .unwrap_err();
8667
8668 assert!(
8669 error.to_string().contains("no ingestion-time block hash"),
8670 "was: {error}"
8671 );
8672 assert!(state.recorded_requests().is_empty());
8673 }
8674
8675 #[tokio::test]
8676 async fn swap_quote_rejects_replaced_ingestion_block() {
8677 let changed = fixture_block_response(FIXTURE_BLOCK, B256::from([0x44; 32]));
8678 let state = execution_rpc_state().with_parameter_response(
8679 "eth_getBlockByNumber",
8680 "0x1cf0d40",
8681 &changed,
8682 );
8683 let (client, _) = client_with_mock_rpc(state).await;
8684 let plan = fixture_sell_plan();
8685 let position = BlockPosition::new(
8686 FIXTURE_BLOCK,
8687 FIXTURE_BLOCK_HASH.to_string(),
8688 BLOCK_SCOPED_SNAPSHOT_INDEX,
8689 BLOCK_SCOPED_SNAPSHOT_INDEX,
8690 )
8691 .with_block_hash(Some(FIXTURE_BLOCK_HASH.to_string()));
8692
8693 let error = validate_swap_quote(
8694 &position,
8695 &plan,
8696 100,
8697 &client.verification,
8698 &client
8699 .config
8700 .verification
8701 .as_ref()
8702 .unwrap()
8703 .deployment_manifest,
8704 )
8705 .await
8706 .unwrap_err();
8707
8708 assert!(error.to_string().contains("changed from"), "was: {error}");
8709 }
8710
8711 #[tokio::test]
8712 async fn swap_quote_accepts_exact_canonical_event_watermark() {
8713 let pool = test_pool();
8714 let receipt = profiler_event_receipt(pool.address);
8715 let state = execution_rpc_state().with_response("eth_getTransactionReceipt", &receipt);
8716 let (client, _) = client_with_mock_rpc(state).await;
8717 let plan = fixture_sell_plan();
8718
8719 let anchors = validate_swap_quote(
8720 &profiler_event_position(),
8721 &plan,
8722 100,
8723 &client.verification,
8724 &client
8725 .config
8726 .verification
8727 .as_ref()
8728 .unwrap()
8729 .deployment_manifest,
8730 )
8731 .await
8732 .unwrap();
8733
8734 assert_eq!(anchors.watermark.number, profiler_event_position().number);
8735 assert_eq!(anchors.state.number, FIXTURE_BLOCK);
8736 assert_eq!(anchors.state.hash, B256::from([0x11; 32]));
8737 assert_eq!(anchors.state.timestamp, FIXTURE_BLOCK_TIMESTAMP);
8738 }
8739
8740 #[tokio::test]
8741 async fn swap_quote_rejects_mismatched_receipt_position() {
8742 let pool = test_pool();
8743 let mut receipt: serde_json::Value =
8744 serde_json::from_str(&profiler_event_receipt(pool.address)).unwrap();
8745 receipt["result"]["transactionIndex"] = serde_json::json!("0x3");
8746 let state =
8747 execution_rpc_state().with_response("eth_getTransactionReceipt", &receipt.to_string());
8748 let (client, _) = client_with_mock_rpc(state).await;
8749 let plan = fixture_sell_plan();
8750
8751 let error = validate_swap_quote(
8752 &profiler_event_position(),
8753 &plan,
8754 100,
8755 &client.verification,
8756 &client
8757 .config
8758 .verification
8759 .as_ref()
8760 .unwrap()
8761 .deployment_manifest,
8762 )
8763 .await
8764 .unwrap_err();
8765
8766 assert_eq!(
8767 error.to_string(),
8768 "Profiler receipt position does not match its ingestion watermark"
8769 );
8770 }
8771
8772 #[tokio::test]
8773 async fn swap_quote_rejects_watermark_from_different_pool() {
8774 let receipt = profiler_event_receipt(ROUTER_ADDRESS);
8775 let state = execution_rpc_state().with_response("eth_getTransactionReceipt", &receipt);
8776 let (client, _) = client_with_mock_rpc(state).await;
8777 let plan = fixture_sell_plan();
8778
8779 let error = validate_swap_quote(
8780 &profiler_event_position(),
8781 &plan,
8782 100,
8783 &client.verification,
8784 &client
8785 .config
8786 .verification
8787 .as_ref()
8788 .unwrap()
8789 .deployment_manifest,
8790 )
8791 .await
8792 .unwrap_err();
8793
8794 assert!(
8795 error
8796 .to_string()
8797 .contains("did not come from expected pool"),
8798 "was: {error}"
8799 );
8800 }
8801
8802 #[rstest]
8803 fn verified_sell_quote_sets_signed_amounts() {
8804 let plan = fixture_sell_plan();
8805 let quote = UniswapV3Quote {
8806 amount: expected_sell_quote_amount(),
8807 sqrt_price_x96_after: U160::from(1u128 << 96),
8808 initialized_ticks_crossed: 0,
8809 gas_estimate: U256::from(50_000u64),
8810 };
8811
8812 let (amount_in, min_amount_out) = verified_swap_amounts(&plan, quote).unwrap();
8813
8814 assert_eq!(amount_in, U256::from(1_000_000_000_000_000u64));
8815 assert_eq!(min_amount_out, expected_min_amount_out(50));
8816 }
8817
8818 #[rstest]
8819 fn exact_output_amount_extracts_negative_leg() {
8820 let pool = test_pool();
8821 let profiler = test_profiler(&pool, FIXTURE_BLOCK);
8822 let quote = profiler
8823 .swap_exact_in(U256::from(1_000_000_000_000_000u64), true, None)
8824 .unwrap();
8825
8826 let amount = exact_output_amount("e, true).unwrap();
8827
8828 assert!(amount < U256::from(1_000_000_000_000_000u64));
8829 assert!(amount > U256::from(990_000_000_000_000u64));
8830
8831 let error = exact_output_amount("e, false).unwrap_err();
8832 assert!(
8833 error.to_string().contains("is not a positive output"),
8834 "was: {error}"
8835 );
8836 }
8837
8838 #[rstest]
8839 #[case("0xC6962004f452bE9203591991D15f6b388e09E8D0.Arbitrum:UniswapV3", true)]
8840 #[case("0xC6962004f452bE9203591991D15f6b388e09E8D0.Ethereum:UniswapV3", false)]
8841 #[case("0xC6962004f452bE9203591991D15f6b388e09E8D0.Arbitrum:UniswapV4", false)]
8842 #[case("ETHUSDT-PERP.BINANCE", false)]
8843 fn handles_order_venue_matches_chain_and_dex(#[case] instrument: &str, #[case] expected: bool) {
8844 let client = test_client("http://127.0.0.1:1".to_string());
8845 let instrument_id: InstrumentId = instrument.parse().unwrap();
8846
8847 assert_eq!(client.handles_order_venue(instrument_id.venue), expected);
8848 }
8849
8850 #[tokio::test]
8851 async fn submit_order_denies_buy_without_allowlisted_pair() {
8852 let (mut client, cache) =
8853 swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
8854 let pool = test_pool();
8855 let order = test_market_buy_order(pool.instrument_id);
8856 cache
8857 .borrow_mut()
8858 .add_order(order.clone(), None, None, true)
8859 .unwrap();
8860 let mut receiver = start_with_events(&mut client);
8861
8862 client.submit_order(submit_order_cmd(&order)).unwrap();
8863
8864 let events = collect_order_events(&mut receiver);
8865 assert_eq!(events.len(), 1);
8866 let OrderEventAny::Denied(denied) = &events[0] else {
8867 panic!("expected OrderDenied, was {:?}", events[0]);
8868 };
8869 assert!(
8870 denied
8871 .reason
8872 .contains("not in the `allowed_token_pairs` allowlist"),
8873 "was: {}",
8874 denied.reason
8875 );
8876 assert!(
8877 denied.reason.contains(&USDC_ADDRESS.to_string()),
8878 "was: {}",
8879 denied.reason
8880 );
8881 }
8882
8883 #[tokio::test]
8884 async fn submit_order_denies_buy_quote_denominated_quantity() {
8885 let (mut client, cache) =
8886 swap_client_with_cache(buy_test_config("http://127.0.0.1:1".to_string()));
8887 let pool = test_pool();
8888 let order = OrderTestBuilder::new(OrderType::Market)
8889 .trader_id(TraderId::from("TRADER-001"))
8890 .strategy_id(StrategyId::from("S-001"))
8891 .instrument_id(pool.instrument_id)
8892 .client_order_id(ClientOrderId::from("O-SWAP-BUY-001"))
8893 .side(OrderSide::Buy)
8894 .quantity(Quantity::from("0.001"))
8895 .quote_quantity(true)
8896 .build();
8897 cache
8898 .borrow_mut()
8899 .add_order(order.clone(), None, None, true)
8900 .unwrap();
8901 let mut receiver = start_with_events(&mut client);
8902
8903 client.submit_order(submit_order_cmd(&order)).unwrap();
8904
8905 let events = collect_order_events(&mut receiver);
8906 assert_eq!(events.len(), 1);
8907 let OrderEventAny::Denied(denied) = &events[0] else {
8908 panic!("expected OrderDenied, was {:?}", events[0]);
8909 };
8910
8911 assert_eq!(denied.reason.as_str(), "UNSUPPORTED_QUOTE_QUANTITY");
8912 }
8913
8914 #[tokio::test]
8915 async fn submit_order_denies_buy_amount_above_max_order_amount() {
8916 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
8917 config.max_order_amount = Some(999_999_999_999_999);
8918 let (mut client, cache) = swap_client_with_cache(config);
8919 let order = test_market_buy_order(test_pool().instrument_id);
8920 cache
8921 .borrow_mut()
8922 .add_order(order.clone(), None, None, true)
8923 .unwrap();
8924 let mut receiver = start_with_events(&mut client);
8925
8926 client.submit_order(submit_order_cmd(&order)).unwrap();
8927
8928 let events = collect_order_events(&mut receiver);
8929 assert_eq!(events.len(), 1);
8930 let OrderEventAny::Denied(denied) = &events[0] else {
8931 panic!("expected OrderDenied, was {:?}", events[0]);
8932 };
8933 assert!(
8934 denied
8935 .reason
8936 .contains("exceeds the configured `max_order_amount`"),
8937 "was: {}",
8938 denied.reason
8939 );
8940 }
8941
8942 #[tokio::test]
8943 async fn submit_order_denies_buy_without_quote_spend_limit() {
8944 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
8945 config.quote_spend_limits = None;
8946 let (mut client, cache) = swap_client_with_cache(config);
8947 let order = test_market_buy_order(test_pool().instrument_id);
8948 cache
8949 .borrow_mut()
8950 .add_order(order.clone(), None, None, true)
8951 .unwrap();
8952 let mut receiver = start_with_events(&mut client);
8953
8954 client.submit_order(submit_order_cmd(&order)).unwrap();
8955
8956 let events = collect_order_events(&mut receiver);
8957 assert_eq!(events.len(), 1);
8958 let OrderEventAny::Denied(denied) = &events[0] else {
8959 panic!("expected OrderDenied, was {:?}", events[0]);
8960 };
8961 assert!(
8962 denied
8963 .reason
8964 .contains("No `quote_spend_limits` entry for BUY token pair"),
8965 "was: {}",
8966 denied.reason
8967 );
8968 }
8969
8970 #[tokio::test]
8971 async fn submit_order_uses_pair_specific_quote_spend_limit() {
8972 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
8973 config.quote_spend_limits = Some(vec![quote_spend_limit(
8974 WETH,
8975 USDC,
8976 18,
8977 "1000000000000000000",
8978 )]);
8979 let (mut client, cache) = swap_client_with_cache(config);
8980 let order = test_market_buy_order(test_pool().instrument_id);
8981 cache
8982 .borrow_mut()
8983 .add_order(order.clone(), None, None, true)
8984 .unwrap();
8985 let mut receiver = start_with_events(&mut client);
8986
8987 client.submit_order(submit_order_cmd(&order)).unwrap();
8988
8989 let events = collect_order_events(&mut receiver);
8990 assert_eq!(events.len(), 1);
8991 let OrderEventAny::Denied(denied) = &events[0] else {
8992 panic!("expected OrderDenied, was {:?}", events[0]);
8993 };
8994 assert!(
8995 denied
8996 .reason
8997 .contains("No `quote_spend_limits` entry for BUY token pair"),
8998 "was: {}",
8999 denied.reason
9000 );
9001 }
9002
9003 #[tokio::test]
9004 async fn submit_order_denies_buy_with_quote_spend_precision_mismatch() {
9005 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
9006 config.quote_spend_limits.as_mut().unwrap()[0].spend_token_decimals = 18;
9007 let error = test_client_result(config, test_pool()).unwrap_err();
9008 assert!(
9009 error
9010 .to_string()
9011 .contains("Quote spend limit decimals do not match the deployment manifest"),
9012 "was: {error}"
9013 );
9014 }
9015
9016 #[tokio::test]
9017 async fn submit_order_denies_buy_with_zero_quote_spend_limit() {
9018 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
9019 config.quote_spend_limits.as_mut().unwrap()[0].max_amount = "0".to_string();
9020 let (mut client, cache) = swap_client_with_cache(config);
9021 let order = test_market_buy_order(test_pool().instrument_id);
9022 cache
9023 .borrow_mut()
9024 .add_order(order.clone(), None, None, true)
9025 .unwrap();
9026 let mut receiver = start_with_events(&mut client);
9027
9028 client.submit_order(submit_order_cmd(&order)).unwrap();
9029
9030 let events = collect_order_events(&mut receiver);
9031 assert_eq!(events.len(), 1);
9032 let OrderEventAny::Denied(denied) = &events[0] else {
9033 panic!("expected OrderDenied, was {:?}", events[0]);
9034 };
9035 assert!(
9036 denied
9037 .reason
9038 .contains("exceeds the configured `quote_spend_limits` maximum 0"),
9039 "was: {}",
9040 denied.reason
9041 );
9042 }
9043
9044 #[tokio::test]
9045 async fn submit_order_denies_buy_one_raw_unit_above_quote_spend_limit_before_readiness() {
9046 let amount_in = expected_buy_amount_in();
9047 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
9048 config.quote_spend_limits.as_mut().unwrap()[0].max_amount =
9049 (amount_in - U256::from(1u8)).to_string();
9050 let (mut client, cache) = swap_client_with_cache(config);
9051 let order = test_market_buy_order(test_pool().instrument_id);
9052 cache
9053 .borrow_mut()
9054 .add_order(order.clone(), None, None, true)
9055 .unwrap();
9056 let mut receiver = start_with_events(&mut client);
9057
9058 client.submit_order(submit_order_cmd(&order)).unwrap();
9059
9060 let events = collect_order_events(&mut receiver);
9061 assert_eq!(events.len(), 1);
9062 let OrderEventAny::Denied(denied) = &events[0] else {
9063 panic!("expected OrderDenied, was {:?}", events[0]);
9064 };
9065 assert!(
9066 denied.reason.contains(&format!(
9067 "BUY quote amount {amount_in} exceeds the configured `quote_spend_limits`"
9068 )),
9069 "was: {}",
9070 denied.reason
9071 );
9072 assert!(!client.core.is_connected());
9073 assert!(!client.cache.has_database());
9074 assert!(client.signer.is_none());
9075 assert!(client.pending_tasks.is_empty());
9076 }
9077
9078 #[tokio::test]
9079 async fn submit_order_denies_non_market_order_type() {
9080 let (mut client, cache) =
9081 swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9082 let pool = test_pool();
9083 let order = OrderTestBuilder::new(OrderType::Limit)
9084 .trader_id(TraderId::from("TRADER-001"))
9085 .strategy_id(StrategyId::from("S-001"))
9086 .instrument_id(pool.instrument_id)
9087 .client_order_id(ClientOrderId::from("O-SWAP-001"))
9088 .side(OrderSide::Sell)
9089 .quantity(Quantity::from("0.001"))
9090 .price(Price::from("2000"))
9091 .build();
9092 cache
9093 .borrow_mut()
9094 .add_order(order.clone(), None, None, true)
9095 .unwrap();
9096 let mut receiver = start_with_events(&mut client);
9097
9098 client.submit_order(submit_order_cmd(&order)).unwrap();
9099
9100 let events = collect_order_events(&mut receiver);
9101 assert_eq!(events.len(), 1);
9102 let OrderEventAny::Denied(denied) = &events[0] else {
9103 panic!("expected OrderDenied, was {:?}", events[0]);
9104 };
9105 assert!(
9106 denied.reason.contains("only Market is supported"),
9107 "was: {}",
9108 denied.reason
9109 );
9110 }
9111
9112 #[tokio::test]
9113 async fn submit_order_denies_quote_denominated_quantity() {
9114 let (mut client, cache) =
9115 swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9116 let pool = test_pool();
9117 let order = OrderTestBuilder::new(OrderType::Market)
9118 .trader_id(TraderId::from("TRADER-001"))
9119 .strategy_id(StrategyId::from("S-001"))
9120 .instrument_id(pool.instrument_id)
9121 .client_order_id(ClientOrderId::from("O-SWAP-001"))
9122 .side(OrderSide::Sell)
9123 .quantity(Quantity::from("0.001"))
9124 .quote_quantity(true)
9125 .build();
9126 cache
9127 .borrow_mut()
9128 .add_order(order.clone(), None, None, true)
9129 .unwrap();
9130 let mut receiver = start_with_events(&mut client);
9131
9132 client.submit_order(submit_order_cmd(&order)).unwrap();
9133
9134 let events = collect_order_events(&mut receiver);
9135 assert_eq!(events.len(), 1);
9136 let OrderEventAny::Denied(denied) = &events[0] else {
9137 panic!("expected OrderDenied, was {:?}", events[0]);
9138 };
9139
9140 assert_eq!(denied.reason.as_str(), "UNSUPPORTED_QUOTE_QUANTITY");
9141 }
9142
9143 #[tokio::test]
9144 async fn submit_order_denies_unknown_pool() {
9145 let (mut client, cache) =
9146 swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9147 let unknown: InstrumentId = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45.Arbitrum:UniswapV3"
9148 .parse()
9149 .unwrap();
9150 let order = OrderTestBuilder::new(OrderType::Market)
9151 .trader_id(TraderId::from("TRADER-001"))
9152 .strategy_id(StrategyId::from("S-001"))
9153 .instrument_id(unknown)
9154 .client_order_id(ClientOrderId::from("O-SWAP-001"))
9155 .side(OrderSide::Sell)
9156 .quantity(Quantity::from("0.001"))
9157 .build();
9158 cache
9159 .borrow_mut()
9160 .add_order(order.clone(), None, None, true)
9161 .unwrap();
9162 let mut receiver = start_with_events(&mut client);
9163
9164 client.submit_order(submit_order_cmd(&order)).unwrap();
9165
9166 let events = collect_order_events(&mut receiver);
9167 assert_eq!(events.len(), 1);
9168 let OrderEventAny::Denied(denied) = &events[0] else {
9169 panic!("expected OrderDenied, was {:?}", events[0]);
9170 };
9171 assert!(
9172 denied.reason.contains("Unknown pool"),
9173 "was: {}",
9174 denied.reason
9175 );
9176 }
9177
9178 #[tokio::test]
9179 async fn submit_order_denies_sell_when_only_buy_pair_allowlisted() {
9180 let mut config = test_config("http://127.0.0.1:1".to_string());
9181 config.allowed_token_pairs = Some(vec![(USDC.to_string(), WETH.to_string())]);
9182 let (mut client, _) = swap_client_with_cache(config);
9183 let order = test_market_sell_order(test_pool().instrument_id);
9184 let mut receiver = start_with_events(&mut client);
9185
9186 client.submit_order(submit_order_cmd(&order)).unwrap();
9187
9188 let events = collect_order_events(&mut receiver);
9189 assert_eq!(events.len(), 1);
9190 let OrderEventAny::Denied(denied) = &events[0] else {
9191 panic!("expected OrderDenied, was {:?}", events[0]);
9192 };
9193 assert!(
9194 denied
9195 .reason
9196 .contains("not in the `allowed_token_pairs` allowlist"),
9197 "was: {}",
9198 denied.reason
9199 );
9200 assert!(
9201 denied.reason.contains(&WETH_ADDRESS.to_string()),
9202 "was: {}",
9203 denied.reason
9204 );
9205 }
9206
9207 #[tokio::test]
9208 async fn submit_order_sell_ignores_quote_spend_limits() {
9209 let mut config = test_config("http://127.0.0.1:1".to_string());
9210 config.quote_spend_limits = Some(vec![quote_spend_limit(WETH, USDC, 18, "0")]);
9211 let (mut client, _) = swap_client_with_cache(config);
9212 let order = test_market_sell_order(test_pool().instrument_id);
9213 let mut receiver = start_with_events(&mut client);
9214
9215 client.submit_order(submit_order_cmd(&order)).unwrap();
9216
9217 let events = collect_order_events(&mut receiver);
9218 assert_eq!(events.len(), 1);
9219 let OrderEventAny::Denied(denied) = &events[0] else {
9220 panic!("expected OrderDenied, was {:?}", events[0]);
9221 };
9222 assert!(
9223 denied
9224 .reason
9225 .contains("Blockchain execution client is not connected"),
9226 "was: {}",
9227 denied.reason
9228 );
9229 }
9230
9231 #[tokio::test]
9232 async fn submit_order_denies_token_pair_outside_allowlist() {
9233 let mut config = test_config("http://127.0.0.1:1".to_string());
9234 config.allowed_token_pairs = Some(Vec::new());
9235 let (mut client, _) = swap_client_with_cache(config);
9236 let order = test_market_sell_order(test_pool().instrument_id);
9237 let mut receiver = start_with_events(&mut client);
9238
9239 client.submit_order(submit_order_cmd(&order)).unwrap();
9240
9241 let events = collect_order_events(&mut receiver);
9242 assert_eq!(events.len(), 1);
9243 let OrderEventAny::Denied(denied) = &events[0] else {
9244 panic!("expected OrderDenied, was {:?}", events[0]);
9245 };
9246 assert!(
9247 denied
9248 .reason
9249 .contains("not in the `allowed_token_pairs` allowlist"),
9250 "was: {}",
9251 denied.reason
9252 );
9253 }
9254
9255 #[tokio::test]
9256 async fn submit_order_denies_amount_above_max_order_amount() {
9257 let mut config = test_config("http://127.0.0.1:1".to_string());
9258 config.max_order_amount = Some(999_999_999_999_999); let (mut client, _) = swap_client_with_cache(config);
9260 let order = test_market_sell_order(test_pool().instrument_id);
9261 let mut receiver = start_with_events(&mut client);
9262
9263 client.submit_order(submit_order_cmd(&order)).unwrap();
9264
9265 let events = collect_order_events(&mut receiver);
9266 assert_eq!(events.len(), 1);
9267 let OrderEventAny::Denied(denied) = &events[0] else {
9268 panic!("expected OrderDenied, was {:?}", events[0]);
9269 };
9270 assert!(
9271 denied
9272 .reason
9273 .contains("exceeds the configured `max_order_amount`"),
9274 "was: {}",
9275 denied.reason
9276 );
9277 }
9278
9279 #[tokio::test]
9280 async fn submit_order_denies_slippage_param_above_ceiling() {
9281 let (mut client, _) = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9282 let order = test_market_sell_order(test_pool().instrument_id);
9283 let mut cmd = submit_order_cmd(&order);
9284 cmd.params = Some(serde_json::from_str(r#"{"slippage_bps": 201}"#).unwrap());
9285 let mut receiver = start_with_events(&mut client);
9286
9287 client.submit_order(cmd).unwrap();
9288
9289 let events = collect_order_events(&mut receiver);
9290 assert_eq!(events.len(), 1);
9291 let OrderEventAny::Denied(denied) = &events[0] else {
9292 panic!("expected OrderDenied, was {:?}", events[0]);
9293 };
9294 assert!(
9295 denied
9296 .reason
9297 .contains("exceeds the configured `max_slippage_bps`"),
9298 "was: {}",
9299 denied.reason
9300 );
9301 }
9302
9303 #[tokio::test]
9304 async fn submit_order_denies_pool_without_fee_tier() {
9305 let addr = start_mock_rpc_server(MockRpcState::default()).await;
9306 let mut pool = test_pool();
9307 pool.fee = None;
9308 let cache = Rc::new(RefCell::new(Cache::default()));
9309 cache.borrow_mut().add_pool(pool.clone()).unwrap();
9310 let order = test_market_sell_order(pool.instrument_id);
9311 cache
9312 .borrow_mut()
9313 .add_order(order.clone(), None, None, false)
9314 .unwrap();
9315 let core = ExecutionClientCore::new(
9316 TraderId::from("TRADER-001"),
9317 ClientId::from("BLOCKCHAIN-001"),
9318 *BLOCKCHAIN_VENUE,
9319 OmsType::Netting,
9320 AccountId::from("BLOCKCHAIN-001"),
9321 AccountType::Wallet,
9322 None,
9323 cache,
9324 );
9325 let mut client =
9326 BlockchainExecutionClient::new(core, test_config(format!("http://{addr}"))).unwrap();
9327 let mut receiver = start_with_events(&mut client);
9328
9329 client.submit_order(submit_order_cmd(&order)).unwrap();
9330
9331 let events = collect_order_events(&mut receiver);
9332 assert_eq!(events.len(), 1);
9333 let OrderEventAny::Denied(denied) = &events[0] else {
9334 panic!("expected OrderDenied, was {:?}", events[0]);
9335 };
9336 assert!(
9337 denied.reason.contains("no fee tier"),
9338 "was: {}",
9339 denied.reason
9340 );
9341 }
9342
9343 #[tokio::test]
9344 async fn submit_order_denies_without_live_profiler() {
9345 let addr = start_mock_rpc_server(MockRpcState::default()).await;
9346 let cache = Rc::new(RefCell::new(Cache::default()));
9347 let pool = test_pool();
9348 cache.borrow_mut().add_pool(pool.clone()).unwrap();
9349 let order = test_market_sell_order(pool.instrument_id);
9350 cache
9351 .borrow_mut()
9352 .add_order(order.clone(), None, None, false)
9353 .unwrap();
9354 let core = ExecutionClientCore::new(
9355 TraderId::from("TRADER-001"),
9356 ClientId::from("BLOCKCHAIN-001"),
9357 *BLOCKCHAIN_VENUE,
9358 OmsType::Netting,
9359 AccountId::from("BLOCKCHAIN-001"),
9360 AccountType::Wallet,
9361 None,
9362 cache,
9363 );
9364 let mut client =
9365 BlockchainExecutionClient::new(core, test_config(format!("http://{addr}"))).unwrap();
9366 let mut receiver = start_with_events(&mut client);
9367
9368 client.submit_order(submit_order_cmd(&order)).unwrap();
9369
9370 let events = collect_order_events(&mut receiver);
9371 assert_eq!(events.len(), 1);
9372 let OrderEventAny::Denied(denied) = &events[0] else {
9373 panic!("expected OrderDenied, was {:?}", events[0]);
9374 };
9375 assert!(
9376 denied.reason.contains("No pool profiler"),
9377 "was: {}",
9378 denied.reason
9379 );
9380 }
9381
9382 #[tokio::test]
9383 async fn submit_order_denies_when_not_connected() {
9384 let (mut client, _) = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9385 let order = test_market_sell_order(test_pool().instrument_id);
9386 let mut receiver = start_with_events(&mut client);
9387
9388 client.submit_order(submit_order_cmd(&order)).unwrap();
9389
9390 let events = collect_order_events(&mut receiver);
9391 assert_eq!(events.len(), 1);
9392 let OrderEventAny::Denied(denied) = &events[0] else {
9393 panic!("expected OrderDenied, was {:?}", events[0]);
9394 };
9395 assert!(
9396 denied.reason.contains("is not connected"),
9397 "was: {}",
9398 denied.reason
9399 );
9400 }
9401
9402 #[tokio::test]
9403 async fn submit_order_denies_without_durable_store() {
9404 let (mut client, _) = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9405 client.core.set_connected();
9406 let order = test_market_sell_order(test_pool().instrument_id);
9407 let mut receiver = start_with_events(&mut client);
9408
9409 client.submit_order(submit_order_cmd(&order)).unwrap();
9410
9411 let events = collect_order_events(&mut receiver);
9412 assert_eq!(events.len(), 1);
9413 let OrderEventAny::Denied(denied) = &events[0] else {
9414 panic!("expected OrderDenied, was {:?}", events[0]);
9415 };
9416 assert!(
9417 denied.reason.contains("No durable store configured"),
9418 "was: {}",
9419 denied.reason
9420 );
9421 }
9422
9423 #[tokio::test]
9424 async fn submit_order_denies_when_transaction_in_flight() {
9425 let (mut client, _) = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9426 client.core.set_connected();
9427 *client.in_flight.lock() = Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
9428 intent_id: 1,
9429 nonce: 7,
9430 tx_hash: B256::ZERO,
9431 purpose: TransactionPurpose::Wrap,
9432 }));
9433 let order = test_market_sell_order(test_pool().instrument_id);
9434 let mut receiver = start_with_events(&mut client);
9435
9436 client.submit_order(submit_order_cmd(&order)).unwrap();
9437
9438 let events = collect_order_events(&mut receiver);
9439 assert_eq!(events.len(), 1);
9440 let OrderEventAny::Denied(denied) = &events[0] else {
9441 panic!("expected OrderDenied, was {:?}", events[0]);
9442 };
9443 assert!(
9444 denied.reason.contains("still awaiting finality"),
9445 "was: {}",
9446 denied.reason
9447 );
9448 }
9449
9450 #[tokio::test]
9451 async fn submit_order_denies_without_signer() {
9452 let Some((admin_pool, schema, mut client, _, _)) =
9453 swap_client_with_database("execution_submit_no_signer_test", swap_rpc_state().await)
9454 .await
9455 else {
9456 return;
9457 };
9458 client.signer = None;
9459 let order = test_market_sell_order(test_pool().instrument_id);
9460 let mut receiver = start_with_events(&mut client);
9461
9462 client.submit_order(submit_order_cmd(&order)).unwrap();
9463
9464 let events = collect_order_events(&mut receiver);
9465 assert_eq!(events.len(), 1);
9466 let OrderEventAny::Denied(denied) = &events[0] else {
9467 panic!("expected OrderDenied, was {:?}", events[0]);
9468 };
9469 assert!(
9470 denied.reason.contains("Signer not initialized"),
9471 "was: {}",
9472 denied.reason
9473 );
9474
9475 drop_execution_schema(&admin_pool, &schema).await;
9476 }
9477
9478 #[tokio::test]
9479 async fn submit_order_broadcasts_swap_and_records_client_order_id() {
9480 let Some((admin_pool, schema, mut client, state, _)) =
9481 swap_client_with_database("execution_submit_success_test", swap_rpc_state().await)
9482 .await
9483 else {
9484 return;
9485 };
9486 let order = test_market_sell_order(test_pool().instrument_id);
9487 let mut receiver = start_with_events(&mut client);
9488 let expected_min_out = expected_min_amount_out(50);
9489
9490 client.submit_order(submit_order_cmd(&order)).unwrap();
9491 await_pending_tasks(&client).await;
9492
9493 let events = collect_order_events(&mut receiver);
9494 assert_swap_submitted_and_filled(&events);
9495 let OrderEventAny::Submitted(submitted) = &events[0] else {
9496 panic!("expected OrderSubmitted, was {:?}", events[0]);
9497 };
9498 assert_eq!(submitted.client_order_id, order.client_order_id());
9499
9500 let (expected_hash, expected_raw) = expected_swap_tx(expected_min_out).await;
9502 let broadcasts: Vec<_> = state
9503 .recorded_requests()
9504 .into_iter()
9505 .filter(|request| request["method"] == "eth_sendRawTransaction")
9506 .collect();
9507 assert_eq!(broadcasts.len(), 1);
9508 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
9509
9510 let record = client
9511 .cache
9512 .get_execution_transaction(42161, &expected_hash.to_string())
9513 .await
9514 .unwrap()
9515 .unwrap();
9516 assert_eq!(record.nonce, 7);
9517 assert_eq!(record.purpose, "swap");
9518 assert_eq!(record.status, "finalized");
9519 assert_eq!(
9520 record.client_order_id.as_deref(),
9521 Some(order.client_order_id().as_str())
9522 );
9523 assert!(client.in_flight.lock().is_none());
9524 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
9525 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
9526 )))
9527 .fetch_one(&admin_pool)
9528 .await
9529 .unwrap();
9530 let decision_count = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(format!(
9531 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
9532 WHERE outcome = 'verified'"
9533 )))
9534 .fetch_one(&admin_pool)
9535 .await
9536 .unwrap();
9537 let connect_decision_count = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(format!(
9538 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
9539 WHERE decision_class = 'connect' AND outcome = 'verified'"
9540 )))
9541 .fetch_one(&admin_pool)
9542 .await
9543 .unwrap();
9544 assert_eq!(nonce_state, (8, 1));
9545 assert_eq!(decision_count, 35);
9546 assert_eq!(connect_decision_count, 1);
9547
9548 drop_execution_schema(&admin_pool, &schema).await;
9549 }
9550
9551 #[tokio::test]
9552 async fn submit_order_pins_every_pre_sign_state_read_to_swap_anchor() {
9553 let Some((admin_pool, schema, mut client, state, _)) =
9554 swap_client_with_database("execution_submit_anchor_reads_test", swap_rpc_state().await)
9555 .await
9556 else {
9557 return;
9558 };
9559 let order = test_market_sell_order(test_pool().instrument_id);
9560 let mut receiver = start_with_events(&mut client);
9561
9562 client.submit_order(submit_order_cmd(&order)).unwrap();
9563 await_pending_tasks(&client).await;
9564
9565 let events = collect_order_events(&mut receiver);
9566 assert_swap_submitted_and_filled(&events);
9567 let requests = state.recorded_requests();
9568 let broadcast_index = requests
9569 .iter()
9570 .position(|request| request["method"] == "eth_sendRawTransaction")
9571 .unwrap();
9572 let pre_broadcast = &requests[..broadcast_index];
9573 let latest_reads: Vec<_> = pre_broadcast
9574 .iter()
9575 .filter(|request| {
9576 request["method"] == "eth_getBlockByNumber" && request["params"][0] == "latest"
9577 })
9578 .collect();
9579 assert_eq!(latest_reads.len(), 3);
9580 assert!(
9581 latest_reads
9582 .iter()
9583 .all(|request| request["params"] == serde_json::json!(["latest", false]))
9584 );
9585
9586 for method in [
9587 "eth_getCode",
9588 "eth_call",
9589 "eth_estimateGas",
9590 "eth_getBalance",
9591 ] {
9592 let pinned: Vec<_> = pre_broadcast
9593 .iter()
9594 .filter(|request| request["method"] == method)
9595 .collect();
9596 assert!(!pinned.is_empty(), "method {method}");
9597 assert_eq!(pinned.len() % 3, 0, "method {method}: {pinned:?}");
9598 assert!(
9599 pinned
9600 .iter()
9601 .all(|request| request["params"][1] == FIXTURE_BLOCK_PARAM),
9602 "method {method}: {pinned:?}"
9603 );
9604 }
9605
9606 let numbered_blocks: Vec<_> = pre_broadcast
9607 .iter()
9608 .filter(|request| {
9609 request["method"] == "eth_getBlockByNumber"
9610 && request["params"][0] == FIXTURE_BLOCK_PARAM
9611 })
9612 .collect();
9613 assert!(!numbered_blocks.is_empty());
9614 assert_eq!(numbered_blocks.len() % 3, 0);
9615 assert!(
9616 numbered_blocks
9617 .iter()
9618 .all(|request| request["params"][1] == false)
9619 );
9620
9621 let chain_ids: Vec<_> = pre_broadcast
9622 .iter()
9623 .filter(|request| request["method"] == "eth_chainId")
9624 .collect();
9625 assert_eq!(chain_ids.len(), 3);
9626 assert!(
9627 chain_ids
9628 .iter()
9629 .all(|request| request["params"] == serde_json::json!([]))
9630 );
9631 let nonces: Vec<_> = pre_broadcast
9632 .iter()
9633 .filter(|request| request["method"] == "eth_getTransactionCount")
9634 .collect();
9635 assert_eq!(nonces.len(), 6);
9636 assert_eq!(
9637 nonces
9638 .iter()
9639 .filter(|request| {
9640 request["params"] == serde_json::json!([WALLET.to_ascii_lowercase(), "pending"])
9641 })
9642 .count(),
9643 3
9644 );
9645 assert_eq!(
9646 nonces
9647 .iter()
9648 .filter(|request| {
9649 request["params"]
9650 == serde_json::json!([WALLET.to_ascii_lowercase(), FIXTURE_BLOCK_PARAM])
9651 })
9652 .count(),
9653 3
9654 );
9655 let priority_fees: Vec<_> = pre_broadcast
9656 .iter()
9657 .filter(|request| request["method"] == "eth_maxPriorityFeePerGas")
9658 .collect();
9659 assert_eq!(priority_fees.len(), 3);
9660 assert!(
9661 priority_fees
9662 .iter()
9663 .all(|request| request["params"] == serde_json::json!([]))
9664 );
9665
9666 let (_, expected_raw) = expected_swap_tx(expected_min_amount_out(50)).await;
9667 assert_eq!(
9668 requests[broadcast_index]["params"][0].as_str().unwrap(),
9669 expected_raw
9670 );
9671 let created_block: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
9672 "SELECT created_block FROM {schema}.execution_intent"
9673 )))
9674 .fetch_one(&admin_pool)
9675 .await
9676 .unwrap();
9677 assert_eq!(created_block, i64::try_from(FIXTURE_BLOCK).unwrap());
9678
9679 drop_execution_schema(&admin_pool, &schema).await;
9680 }
9681
9682 #[tokio::test]
9683 async fn submit_order_denies_changed_swap_anchor_before_signing() {
9684 let canonical = fixture_block_response(FIXTURE_BLOCK, B256::from([0x11; 32]));
9685 let changed = fixture_block_response(FIXTURE_BLOCK, B256::from([0x44; 32]));
9686 let state = swap_rpc_state().await.with_parameter_response_sequence(
9687 "eth_getBlockByNumber",
9688 FIXTURE_BLOCK_PARAM,
9689 &[
9690 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9691 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9692 &canonical, &changed, &changed, &changed,
9693 ],
9694 );
9695 let Some((admin_pool, schema, mut client, state, _)) =
9696 swap_client_with_database("execution_submit_anchor_change_test", state).await
9697 else {
9698 return;
9699 };
9700 let order = test_market_sell_order(test_pool().instrument_id);
9701 let mut receiver = start_with_events(&mut client);
9702
9703 client.submit_order(submit_order_cmd(&order)).unwrap();
9704 await_pending_tasks(&client).await;
9705
9706 let events = collect_order_events(&mut receiver);
9707 assert_eq!(events.len(), 1, "was: {events:?}");
9708 let OrderEventAny::Denied(denied) = &events[0] else {
9709 panic!("expected OrderDenied, was {:?}", events[0]);
9710 };
9711 assert!(
9712 denied
9713 .reason
9714 .contains("pre-sign checkpoint reread verification disagreed"),
9715 "was: {}",
9716 denied.reason
9717 );
9718 assert_eq!(
9719 execution_intent_markers(&admin_pool, &schema).await,
9720 vec![("swap".into(), "recoverable".into(), false, false)]
9721 );
9722 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
9723 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
9724 )))
9725 .fetch_one(&admin_pool)
9726 .await
9727 .unwrap();
9728 assert_eq!(signed_count, 0);
9729 assert!(
9730 state
9731 .recorded_requests()
9732 .iter()
9733 .all(|request| { request["method"] != "eth_sendRawTransaction" })
9734 );
9735 assert!(client.in_flight.lock().is_none());
9736
9737 drop_execution_schema(&admin_pool, &schema).await;
9738 }
9739
9740 #[tokio::test]
9741 async fn submit_order_keeps_verified_swap_anchor_when_latest_advances() {
9742 let canonical = fixture_block_response(FIXTURE_BLOCK, B256::from([0x11; 32]));
9743 let newer = fixture_block_response(FIXTURE_BLOCK + 1, B256::from([0x44; 32]));
9744 let state = swap_rpc_state().await.with_parameter_response_sequence(
9745 "eth_getBlockByNumber",
9746 "latest",
9747 &[&canonical, &canonical, &canonical, &newer, &newer, &newer],
9748 );
9749 let Some((admin_pool, schema, mut client, state, _)) =
9750 swap_client_with_database("execution_submit_advancing_head_test", state).await
9751 else {
9752 return;
9753 };
9754 let order = test_market_sell_order(test_pool().instrument_id);
9755 let mut receiver = start_with_events(&mut client);
9756
9757 client.submit_order(submit_order_cmd(&order)).unwrap();
9758 await_pending_tasks(&client).await;
9759
9760 let events = collect_order_events(&mut receiver);
9761 assert_swap_submitted_and_filled(&events);
9762 let latest_reads = state
9763 .recorded_requests()
9764 .iter()
9765 .filter(|request| {
9766 request["method"] == "eth_getBlockByNumber" && request["params"][0] == "latest"
9767 })
9768 .count();
9769 assert_eq!(latest_reads, 3);
9770
9771 drop_execution_schema(&admin_pool, &schema).await;
9772 }
9773
9774 #[tokio::test]
9775 async fn submit_order_denies_changed_quote_watermark_before_signing() {
9776 let watermark_number = FIXTURE_BLOCK;
9777 let watermark_hash = B256::from([0x11; 32]);
9778 let canonical = fixture_block_response(watermark_number, watermark_hash);
9779 let changed = fixture_block_response(watermark_number, B256::from([0x66; 32]));
9780 let watermark_param = format!("0x{watermark_number:x}");
9781 let min_amount_out = expected_min_amount_out(50);
9782 let (tx_hash, _) = expected_swap_tx(min_amount_out).await;
9783 let head = finalized_swap_block(tx_hash, min_amount_out);
9784 let state = swap_rpc_state()
9785 .await
9786 .with_response("eth_getBlockByNumber", &head)
9787 .with_parameter_response_sequence(
9788 "eth_getBlockByNumber",
9789 &watermark_param,
9790 &[
9791 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9792 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9793 &changed, &changed, &changed,
9794 ],
9795 );
9796 let Some((admin_pool, schema, mut client, state, cache)) =
9797 swap_client_with_database("execution_submit_watermark_change_test", state).await
9798 else {
9799 return;
9800 };
9801 let pool = test_pool();
9802 cache
9803 .borrow_mut()
9804 .add_pool_profiler(test_profiler_at_block(
9805 &pool,
9806 watermark_number,
9807 &watermark_hash.to_string(),
9808 ))
9809 .unwrap();
9810 let order = test_market_sell_order(pool.instrument_id);
9811 let mut receiver = start_with_events(&mut client);
9812
9813 client.submit_order(submit_order_cmd(&order)).unwrap();
9814 await_pending_tasks(&client).await;
9815
9816 let events = collect_order_events(&mut receiver);
9817 assert_eq!(events.len(), 1, "was: {events:?}");
9818 let OrderEventAny::Denied(denied) = &events[0] else {
9819 panic!("expected OrderDenied, was {:?}", events[0]);
9820 };
9821 assert!(
9822 denied.reason.contains("changed before signing"),
9823 "was: {}",
9824 denied.reason
9825 );
9826 assert_eq!(
9827 execution_intent_markers(&admin_pool, &schema).await,
9828 vec![("swap".into(), "recoverable".into(), false, false)]
9829 );
9830 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
9831 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
9832 )))
9833 .fetch_one(&admin_pool)
9834 .await
9835 .unwrap();
9836 assert_eq!(signed_count, 0);
9837 assert!(
9838 state
9839 .recorded_requests()
9840 .iter()
9841 .all(|request| { request["method"] != "eth_sendRawTransaction" })
9842 );
9843 assert!(client.in_flight.lock().is_none());
9844
9845 drop_execution_schema(&admin_pool, &schema).await;
9846 }
9847
9848 #[tokio::test]
9849 async fn changed_swap_anchor_retains_ownership_when_recovery_commit_fails() {
9850 let canonical = fixture_block_response(FIXTURE_BLOCK, B256::from([0x11; 32]));
9851 let changed = fixture_block_response(FIXTURE_BLOCK, B256::from([0x44; 32]));
9852 let state = swap_rpc_state().await.with_parameter_response_sequence(
9853 "eth_getBlockByNumber",
9854 FIXTURE_BLOCK_PARAM,
9855 &[
9856 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9857 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9858 &canonical, &changed, &changed, &changed,
9859 ],
9860 );
9861 let Some((admin_pool, schema, mut client, state, _)) =
9862 swap_client_with_database("execution_submit_anchor_recovery_fail_test", state).await
9863 else {
9864 return;
9865 };
9866 install_recoverable_commit_rejection(&admin_pool, &schema).await;
9867 let order = test_market_sell_order(test_pool().instrument_id);
9868 let mut receiver = start_with_events(&mut client);
9869
9870 client.submit_order(submit_order_cmd(&order)).unwrap();
9871 await_pending_tasks(&client).await;
9872
9873 let events = collect_order_events(&mut receiver);
9874 assert_eq!(events.len(), 1, "was: {events:?}");
9875 let OrderEventAny::Denied(denied) = &events[0] else {
9876 panic!("expected OrderDenied, was {:?}", events[0]);
9877 };
9878 assert!(
9879 denied
9880 .reason
9881 .contains("pre-sign checkpoint reread verification disagreed"),
9882 "was: {}",
9883 denied.reason
9884 );
9885 assert_eq!(
9886 execution_intent_markers(&admin_pool, &schema).await,
9887 vec![("swap".into(), "prepared".into(), false, true)]
9888 );
9889 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
9890 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
9891 )))
9892 .fetch_one(&admin_pool)
9893 .await
9894 .unwrap();
9895 assert_eq!(signed_count, 0);
9896 assert!(
9897 state
9898 .recorded_requests()
9899 .iter()
9900 .all(|request| { request["method"] != "eth_sendRawTransaction" })
9901 );
9902 assert!(matches!(
9903 *client.in_flight.lock(),
9904 Some(InFlightSlot::Preparing(TransactionPurpose::Swap))
9905 ));
9906
9907 drop_execution_schema(&admin_pool, &schema).await;
9908 }
9909
9910 #[rstest]
9911 #[case::filled("execution_inflight_finality_fill_test", false)]
9912 #[case::reverted("execution_inflight_finality_revert_test", true)]
9913 #[tokio::test]
9914 async fn swap_awaiting_finality_survives_inflight_exhaustion(
9915 #[case] test_name: &str,
9916 #[case] reverted: bool,
9917 ) {
9918 let min_amount_out = expected_min_amount_out(50);
9919 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
9920
9921 let state = if reverted {
9922 let block = finalized_swap_block(expected_hash, min_amount_out);
9923 let receipt = receipt_with_transaction_hash(RECEIPT_REVERTED, expected_hash);
9924 with_finalized_identity(
9925 swap_rpc_state()
9926 .await
9927 .with_response("eth_getTransactionReceipt", &receipt),
9928 &block,
9929 &receipt,
9930 )
9931 } else {
9932 finalized_swap_rpc_state(expected_hash, min_amount_out)
9933 };
9934
9935 let Some((admin_pool, schema, mut client, _state, cache)) =
9936 swap_client_with_database(test_name, state).await
9937 else {
9938 return;
9939 };
9940
9941 let order = test_market_sell_order(test_pool().instrument_id);
9942 let client_order_id = order.client_order_id();
9943 let mut receiver = start_with_events(&mut client);
9944
9945 let plan = client
9946 .prepare_swap(&submit_order_cmd(&order), &order)
9947 .unwrap();
9948 execute_swap(
9949 plan,
9950 client.transaction_executor().unwrap(),
9951 client.emitter.clone(),
9952 client.transaction_limits.max_quote_age_blocks,
9953 client.transaction_limits.deadline_seconds,
9954 )
9955 .await
9956 .unwrap();
9957 let swap_events = collect_order_events(&mut receiver);
9958 assert_eq!(swap_events.len(), 2, "was: {swap_events:?}");
9959 assert!(matches!(&swap_events[0], OrderEventAny::Submitted(_)));
9960
9961 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
9962 let config = ExecutionManagerConfig::default();
9963 let max_retries = config.inflight_max_retries;
9964 let mut manager = ExecutionManager::new(clock, Rc::clone(&cache), config).unwrap();
9965
9966 assert!(manager.check_open_orders(&[&client]).await.is_empty());
9968 cache.borrow_mut().update_order(&swap_events[0]).unwrap();
9969 tokio::time::pause();
9970 manager.register_submission(order.init_event(), Some(client.client_id()));
9971
9972 let mut inflight_events = Vec::new();
9974 let mut queries_sent = 0;
9975
9976 for _ in 0..60 {
9977 tokio::time::advance(Duration::from_secs(1)).await;
9978 let result = manager.check_inflight_orders();
9979 inflight_events.extend(result.events);
9980
9981 for query in result.queries {
9982 let TradingCommand::QueryOrder(query) = query else {
9983 panic!("expected QueryOrder, was {query:?}");
9984 };
9985
9986 client.query_order(query).unwrap();
9987 queries_sent += 1;
9988 }
9989 }
9990
9991 let exhaustions = manager.take_submission_recovery_exhaustions();
9992 let status_before_finality = cache.borrow().order(&client_order_id).unwrap().status();
9993 cache.borrow_mut().update_order(&swap_events[1]).unwrap();
9994 tokio::time::resume();
9995 drop_execution_schema(&admin_pool, &schema).await;
9996
9997 assert!(inflight_events.is_empty(), "was: {inflight_events:?}");
9998 assert_eq!(queries_sent, max_retries - 1);
9999 assert!(collect_order_events(&mut receiver).is_empty());
10000 assert_eq!(
10001 exhaustions,
10002 vec![SubmissionRecoveryExhausted {
10003 trader_id: order.trader_id(),
10004 client_id: Some(client.client_id()),
10005 strategy_id: order.strategy_id(),
10006 instrument_id: order.instrument_id(),
10007 client_order_id,
10008 source: SubmissionRecoverySource::Inflight,
10009 retry_count: max_retries,
10010 ts_event: UnixNanos::default(),
10011 }]
10012 );
10013 assert_eq!(status_before_finality, OrderStatus::Submitted);
10014 let cache = cache.borrow();
10015 let resolved = cache.order(&client_order_id).unwrap();
10016
10017 if reverted {
10018 let OrderEventAny::Rejected(rejected) = &swap_events[1] else {
10019 panic!("expected OrderRejected, was {:?}", swap_events[1]);
10020 };
10021
10022 assert_eq!(
10023 rejected.reason.as_str(),
10024 format!("Transaction {expected_hash} reverted on-chain")
10025 );
10026 assert!(!rejected.reconciliation);
10027 assert_eq!(resolved.status(), OrderStatus::Rejected);
10028 assert_eq!(
10029 resolved.filled_qty(),
10030 Quantity::zero(order.quantity().precision)
10031 );
10032 } else {
10033 assert!(matches!(&swap_events[1], OrderEventAny::Filled(_)));
10034 assert_eq!(resolved.status(), OrderStatus::Filled);
10035 assert_eq!(resolved.filled_qty(), order.quantity());
10036 }
10037 }
10038
10039 #[tokio::test]
10040 async fn finalized_swap_emits_exact_fill_once_and_refreshes_wallet() {
10041 let min_amount_out = expected_min_amount_out(50);
10042 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
10043 let state = finalized_swap_rpc_state(expected_hash, min_amount_out);
10044 let Some((admin_pool, schema, mut client, state, _)) =
10045 swap_client_with_database("execution_submit_fill_test", state).await
10046 else {
10047 return;
10048 };
10049 let order = test_market_sell_order(test_pool().instrument_id);
10050 let mut receiver = start_with_events(&mut client);
10051
10052 let plan = client
10053 .prepare_swap(&submit_order_cmd(&order), &order)
10054 .unwrap();
10055 execute_swap(
10056 plan,
10057 client.transaction_executor().unwrap(),
10058 client.emitter.clone(),
10059 client.transaction_limits.max_quote_age_blocks,
10060 client.transaction_limits.deadline_seconds,
10061 )
10062 .await
10063 .unwrap();
10064
10065 let (nonce, wallet_address, transaction_to, transaction_input, transaction_value): (
10066 i64,
10067 String,
10068 String,
10069 String,
10070 String,
10071 ) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10072 "SELECT nonce, wallet_address, transaction_to, transaction_input, transaction_value \
10073 FROM {schema}.execution_intent"
10074 )))
10075 .fetch_one(&admin_pool)
10076 .await
10077 .unwrap();
10078 let finalized_block = client
10079 .http_rpc_client
10080 .block_by_number(FIXTURE_BLOCK + 1, true)
10081 .await
10082 .unwrap();
10083 let finalized_transaction = finalized_block
10084 .transactions
10085 .iter()
10086 .find(|transaction| transaction.hash == expected_hash)
10087 .unwrap();
10088 assert_eq!(finalized_transaction.from.to_string(), wallet_address);
10089 assert_eq!(finalized_transaction.nonce, u64::try_from(nonce).unwrap());
10090 assert_eq!(
10091 finalized_transaction.to,
10092 Some(Address::from_str(&transaction_to).unwrap())
10093 );
10094 assert_eq!(
10095 finalized_transaction.input.as_ref(),
10096 hex::decode(transaction_input.strip_prefix("0x").unwrap()).unwrap()
10097 );
10098 assert_eq!(
10099 finalized_transaction.value,
10100 U256::from_str(&transaction_value).unwrap()
10101 );
10102
10103 let mut order_events = Vec::new();
10104 let mut account_states = Vec::new();
10105
10106 while let Ok(event) = receiver.try_recv() {
10107 match event {
10108 ExecutionEvent::Order(event) => order_events.push(event),
10109 ExecutionEvent::Account(state) => account_states.push(state),
10110 other => panic!("unexpected execution event: {other:?}"),
10111 }
10112 }
10113 assert_eq!(order_events.len(), 2, "was: {order_events:?}");
10114 assert!(matches!(&order_events[0], OrderEventAny::Submitted(_)));
10115 let OrderEventAny::Filled(fill) = &order_events[1] else {
10116 panic!("expected OrderFilled, was {:?}", order_events[1]);
10117 };
10118 let expected_commission = Money::from_u256(
10119 U256::from(50_112_u64) * U256::from(100_000_000_u64),
10120 test_pool().chain.native_currency(),
10121 )
10122 .unwrap();
10123 assert_eq!(fill.client_order_id, order.client_order_id());
10124 assert_eq!(fill.venue_order_id.as_str(), expected_hash.to_string());
10125 assert_eq!(fill.order_side, OrderSide::Sell);
10126 assert_eq!(fill.last_qty, Quantity::from("0.001"));
10127 assert_eq!(fill.last_px, Price::from("1000"));
10128 assert_eq!(fill.currency.code, "USDC");
10129 assert_eq!(fill.commission, Some(expected_commission));
10130 assert_eq!(fill.liquidity_side, LiquiditySide::Taker);
10131 assert_eq!(account_states.len(), 1);
10132 let account_state = &account_states[0];
10133 assert_eq!(account_state.account_id, AccountId::from("BLOCKCHAIN-001"));
10134 assert_eq!(account_state.account_type, AccountType::Wallet);
10135 assert_eq!(account_state.base_currency, None);
10136 assert_eq!(account_state.balances.len(), 3);
10137 assert!(account_state.margins.is_empty());
10138 assert!(account_state.is_reported);
10139 assert_eq!(
10140 account_state.balances,
10141 client.wallet_balance.lock().as_account_balances().unwrap()
10142 );
10143
10144 let (fill_emitted, active): (bool, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10145 "SELECT fill_emitted, active FROM {schema}.execution_intent"
10146 )))
10147 .fetch_one(&admin_pool)
10148 .await
10149 .unwrap();
10150 assert!(fill_emitted);
10151 assert!(!active);
10152
10153 let database = client.cache.database.as_ref().unwrap().clone();
10154 let payload_keys = client.payload_keys.clone();
10155 let restart_config = client.config.clone();
10156 drop(client);
10157 let (mut restarted, _) = swap_client_with_cache(restart_config);
10158 restarted.cache.database = Some(database);
10159 restarted.payload_keys = payload_keys;
10160 restarted.signer = Some(Arc::new(
10161 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
10162 ));
10163 let mut restart_receiver = start_with_events(&mut restarted);
10164 restarted.reconcile_unresolved_execution().await.unwrap();
10165 restarted.reconcile_unresolved_execution().await.unwrap();
10166 assert!(collect_order_events(&mut restart_receiver).is_empty());
10167 let requests = state.recorded_requests();
10168 assert_eq!(
10169 requests
10170 .iter()
10171 .filter(|request| request["method"] == "eth_sendRawTransaction")
10172 .count(),
10173 1
10174 );
10175 assert_eq!(
10176 requests
10177 .iter()
10178 .filter(|request| {
10179 request["method"] == "eth_call"
10180 && request["params"][0]["data"]
10181 .as_str()
10182 .is_some_and(|data| data.starts_with(BALANCE_OF_SELECTOR))
10183 })
10184 .count(),
10185 9
10186 );
10187 assert_eq!(
10188 requests
10189 .iter()
10190 .filter(|request| request["method"] == "eth_getBalance")
10191 .count(),
10192 6
10193 );
10194
10195 drop_execution_schema(&admin_pool, &schema).await;
10196 }
10197
10198 #[tokio::test]
10199 async fn restart_emits_committed_swap_from_verified_inclusion_header() {
10200 let min_amount_out = expected_min_amount_out(50);
10201 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
10202 let state = finalized_swap_rpc_state(expected_hash, min_amount_out);
10203 let Some((admin_pool, schema, mut client, _, _)) =
10204 swap_client_with_database("execution_committed_fill_restart_test", state).await
10205 else {
10206 return;
10207 };
10208 let order = test_market_sell_order(test_pool().instrument_id);
10209 let mut receiver = start_with_events(&mut client);
10210 let plan = client
10211 .prepare_swap(&submit_order_cmd(&order), &order)
10212 .unwrap();
10213
10214 execute_swap(
10215 plan,
10216 client.transaction_executor().unwrap(),
10217 client.emitter.clone(),
10218 client.transaction_limits.max_quote_age_blocks,
10219 client.transaction_limits.deadline_seconds,
10220 )
10221 .await
10222 .unwrap();
10223 assert_swap_submitted_and_filled(&collect_order_events(&mut receiver));
10224 let ancestry_range: (i64, i64) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10225 "SELECT height_start, height_end FROM {schema}.execution_verification_decision \
10226 WHERE decision_class = 'finality' AND read_class = 'numbered_block' \
10227 AND height_end > height_start"
10228 )))
10229 .fetch_one(&admin_pool)
10230 .await
10231 .unwrap();
10232 assert_eq!(
10233 ancestry_range,
10234 ((FIXTURE_BLOCK + 1) as i64, (FIXTURE_BLOCK + 2) as i64)
10235 );
10236
10237 sqlx::query(sqlx::AssertSqlSafe(format!(
10238 "UPDATE {schema}.execution_intent SET fill_emitted = FALSE, active = TRUE"
10239 )))
10240 .execute(&admin_pool)
10241 .await
10242 .unwrap();
10243 let database = client.cache.database.as_ref().unwrap().clone();
10244 let payload_keys = client.payload_keys.clone();
10245 let restart_config = client.config.clone();
10246 drop(client);
10247 let (mut restarted, _) = swap_client_with_cache(restart_config);
10248 restarted.cache.database = Some(database);
10249 restarted.payload_keys = payload_keys;
10250 restarted.signer = Some(Arc::new(
10251 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
10252 ));
10253 let mut restart_receiver = start_with_events(&mut restarted);
10254
10255 restarted.reconcile_unresolved_execution().await.unwrap();
10256
10257 let events = collect_order_events(&mut restart_receiver);
10258 let (fill_emitted, active): (bool, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10259 "SELECT fill_emitted, active FROM {schema}.execution_intent"
10260 )))
10261 .fetch_one(&admin_pool)
10262 .await
10263 .unwrap();
10264 assert_eq!(events.len(), 1, "was: {events:?}");
10265 assert!(matches!(&events[0], OrderEventAny::Filled(_)));
10266 assert!(fill_emitted);
10267 assert!(!active);
10268
10269 drop_execution_schema(&admin_pool, &schema).await;
10270 }
10271
10272 #[tokio::test]
10273 async fn finalized_swap_ignores_unrelated_swap_logs() {
10274 let min_amount_out = expected_min_amount_out(50);
10275 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
10276 let receipt = finalized_swap_receipt_with_unrelated_swap(expected_hash);
10277 let state = finalized_swap_rpc_state(expected_hash, min_amount_out)
10278 .with_response("eth_getTransactionReceipt", &receipt);
10279 let Some((admin_pool, schema, mut client, _, _)) =
10280 swap_client_with_database("execution_submit_unrelated_log_test", state).await
10281 else {
10282 return;
10283 };
10284 let order = test_market_sell_order(test_pool().instrument_id);
10285 let mut receiver = start_with_events(&mut client);
10286
10287 client.submit_order(submit_order_cmd(&order)).unwrap();
10288 await_pending_tasks(&client).await;
10289
10290 let events = collect_order_events(&mut receiver);
10291 assert_swap_submitted_and_filled(&events);
10292
10293 drop_execution_schema(&admin_pool, &schema).await;
10294 }
10295
10296 #[tokio::test]
10297 async fn prepare_swap_buy_accepts_quote_spend_exact_boundary() {
10298 let amount_in = expected_buy_amount_in();
10299 let min_amount_out = expected_buy_min_amount_out(50);
10300 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10301 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10302 let max_amount = amount_in.to_string();
10303 let Some((admin_pool, schema, client, _, cache)) = swap_client_with_database_config(
10304 "execution_prepare_buy_test",
10305 state,
10306 move |http_rpc_url| {
10307 let mut config = buy_test_config(http_rpc_url);
10308 config.quote_spend_limits.as_mut().unwrap()[0].max_amount = max_amount;
10309 config
10310 },
10311 )
10312 .await
10313 else {
10314 return;
10315 };
10316 let order = test_market_buy_order(test_pool().instrument_id);
10317 cache
10318 .borrow_mut()
10319 .add_order(order.clone(), None, None, true)
10320 .unwrap();
10321
10322 let plan = client
10323 .prepare_swap(&submit_order_cmd(&order), &order)
10324 .unwrap();
10325
10326 assert_eq!(plan.token_in, USDC_ADDRESS);
10327 assert_eq!(plan.token_out, WETH_ADDRESS);
10328 assert_eq!(plan.amount_in, amount_in);
10329 assert_eq!(plan.min_amount_out, min_amount_out);
10330 assert_ne!(plan.token_in, WETH_ADDRESS);
10331
10332 drop_execution_schema(&admin_pool, &schema).await;
10333 }
10334
10335 #[tokio::test]
10336 async fn submit_order_broadcasts_buy_swap() {
10337 let amount_in = expected_buy_amount_in();
10338 let min_amount_out = expected_buy_min_amount_out(50);
10339 let (expected_hash, expected_raw) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10340 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10341 let Some((admin_pool, schema, mut client, state, cache)) =
10342 swap_client_with_buy_database("execution_submit_buy_success_test", state).await
10343 else {
10344 return;
10345 };
10346 let order = test_market_buy_order(test_pool().instrument_id);
10347 cache
10348 .borrow_mut()
10349 .add_order(order.clone(), None, None, true)
10350 .unwrap();
10351 let mut receiver = start_with_events(&mut client);
10352
10353 client.submit_order(submit_order_cmd(&order)).unwrap();
10354 await_pending_tasks(&client).await;
10355
10356 let events = collect_order_events(&mut receiver);
10357 assert_swap_submitted_and_filled(&events);
10358 let broadcasts: Vec<_> = state
10359 .recorded_requests()
10360 .into_iter()
10361 .filter(|request| request["method"] == "eth_sendRawTransaction")
10362 .collect();
10363 assert_eq!(broadcasts.len(), 1);
10364 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
10365
10366 drop_execution_schema(&admin_pool, &schema).await;
10367 }
10368
10369 #[tokio::test]
10370 async fn finalized_buy_swap_emits_fill_from_output_leg() {
10371 let amount_in = expected_buy_amount_in();
10372 let min_amount_out = expected_buy_min_amount_out(50);
10373 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10374 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10375 let Some((admin_pool, schema, mut client, state, cache)) =
10376 swap_client_with_buy_database("execution_submit_buy_fill_test", state).await
10377 else {
10378 return;
10379 };
10380 let order = test_market_buy_order(test_pool().instrument_id);
10381 cache
10382 .borrow_mut()
10383 .add_order(order.clone(), None, None, true)
10384 .unwrap();
10385 let mut receiver = start_with_events(&mut client);
10386 let plan = client
10387 .prepare_swap(&submit_order_cmd(&order), &order)
10388 .unwrap();
10389
10390 execute_swap(
10391 plan,
10392 client.transaction_executor().unwrap(),
10393 client.emitter.clone(),
10394 client.transaction_limits.max_quote_age_blocks,
10395 client.transaction_limits.deadline_seconds,
10396 )
10397 .await
10398 .unwrap();
10399
10400 let mut order_events = Vec::new();
10401
10402 while let Ok(event) = receiver.try_recv() {
10403 if let ExecutionEvent::Order(event) = event {
10404 order_events.push(event);
10405 }
10406 }
10407 assert_eq!(order_events.len(), 2, "was: {order_events:?}");
10408 assert!(matches!(&order_events[0], OrderEventAny::Submitted(_)));
10409 let OrderEventAny::Filled(fill) = &order_events[1] else {
10410 panic!("expected OrderFilled, was {:?}", order_events[1]);
10411 };
10412 let expected_commission = Money::from_u256(
10413 U256::from(50_112_u64) * U256::from(100_000_000_u64),
10414 test_pool().chain.native_currency(),
10415 )
10416 .unwrap();
10417 assert_eq!(fill.client_order_id, order.client_order_id());
10418 assert_eq!(fill.venue_order_id.as_str(), expected_hash.to_string());
10419 assert_eq!(fill.order_side, OrderSide::Buy);
10420 assert_eq!(fill.last_qty, Quantity::from("0.001"));
10421 assert_eq!(fill.currency.code, "USDC");
10422 assert_eq!(fill.commission, Some(expected_commission));
10423 assert_eq!(fill.liquidity_side, LiquiditySide::Taker);
10424 assert_eq!(
10425 Money::from_decimal(
10426 fill.last_qty.as_decimal() * fill.last_px.as_decimal(),
10427 fill.currency,
10428 )
10429 .unwrap(),
10430 Money::from_u256(amount_in, fill.currency).unwrap()
10431 );
10432
10433 let (fill_emitted, active): (bool, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10434 "SELECT fill_emitted, active FROM {schema}.execution_intent"
10435 )))
10436 .fetch_one(&admin_pool)
10437 .await
10438 .unwrap();
10439 assert!(fill_emitted);
10440 assert!(!active);
10441
10442 let database = client.cache.database.as_ref().unwrap().clone();
10443 let payload_keys = client.payload_keys.clone();
10444 let restart_config = client.config.clone();
10445 drop(client);
10446 let (mut restarted, _) = swap_client_with_cache(restart_config);
10447 restarted.cache.database = Some(database);
10448 restarted.payload_keys = payload_keys;
10449 restarted.signer = Some(Arc::new(
10450 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
10451 ));
10452 let mut restart_receiver = start_with_events(&mut restarted);
10453 restarted.reconcile_unresolved_execution().await.unwrap();
10454 restarted.reconcile_unresolved_execution().await.unwrap();
10455 assert!(collect_order_events(&mut restart_receiver).is_empty());
10456 assert_eq!(
10457 state
10458 .recorded_requests()
10459 .iter()
10460 .filter(|request| request["method"] == "eth_sendRawTransaction")
10461 .count(),
10462 1
10463 );
10464
10465 drop_execution_schema(&admin_pool, &schema).await;
10466 }
10467
10468 #[tokio::test]
10469 async fn finalized_buy_swap_cancels_remainder_when_output_is_short() {
10470 let amount_in = expected_buy_amount_in();
10471 let min_amount_out = expected_buy_min_amount_out(50);
10472 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10473 let short_base_out = min_amount_out;
10474 let mut state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10475 state = state.with_response(
10476 "eth_getTransactionReceipt",
10477 &finalized_buy_swap_receipt_with_base_out(expected_hash, amount_in, short_base_out),
10478 );
10479 let Some((admin_pool, schema, mut client, _, cache)) =
10480 swap_client_with_buy_database("execution_submit_buy_short_fill_test", state).await
10481 else {
10482 return;
10483 };
10484 let order = test_market_buy_order(test_pool().instrument_id);
10485 cache
10486 .borrow_mut()
10487 .add_order(order.clone(), None, None, true)
10488 .unwrap();
10489 let mut receiver = start_with_events(&mut client);
10490 let plan = client
10491 .prepare_swap(&submit_order_cmd(&order), &order)
10492 .unwrap();
10493
10494 execute_swap(
10495 plan,
10496 client.transaction_executor().unwrap(),
10497 client.emitter.clone(),
10498 client.transaction_limits.max_quote_age_blocks,
10499 client.transaction_limits.deadline_seconds,
10500 )
10501 .await
10502 .unwrap();
10503
10504 let events = collect_order_events(&mut receiver);
10505 assert_eq!(events.len(), 3, "was: {events:?}");
10506 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
10507 let OrderEventAny::Filled(fill) = &events[1] else {
10508 panic!("expected OrderFilled, was {:?}", events[1]);
10509 };
10510 let expected_qty = raw_amount_to_quantity(short_base_out, 18).unwrap();
10511 assert_eq!(fill.order_side, OrderSide::Buy);
10512 assert_eq!(fill.last_qty, expected_qty);
10513 assert!(fill.last_qty < order.quantity());
10514 let OrderEventAny::Canceled(canceled) = &events[2] else {
10515 panic!("expected OrderCanceled, was {:?}", events[2]);
10516 };
10517 assert_eq!(canceled.client_order_id, order.client_order_id());
10518 assert_eq!(
10519 canceled.venue_order_id.as_ref().map(VenueOrderId::as_str),
10520 Some(expected_hash.to_string().as_str())
10521 );
10522
10523 drop_execution_schema(&admin_pool, &schema).await;
10524 }
10525
10526 #[tokio::test]
10527 async fn finalized_buy_swap_reports_full_output_when_above_order_quantity() {
10528 let amount_in = expected_buy_amount_in();
10529 let min_amount_out = expected_buy_min_amount_out(50);
10530 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10531 let overshoot_base_out = expected_buy_base_amount() * U256::from(2) + U256::from(44);
10532 let mut state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10533 state = state.with_response(
10534 "eth_getTransactionReceipt",
10535 &finalized_buy_swap_receipt_with_base_out(expected_hash, amount_in, overshoot_base_out),
10536 );
10537 let Some((admin_pool, schema, mut client, _, cache)) =
10538 swap_client_with_buy_database("execution_submit_buy_overshoot_fill_test", state).await
10539 else {
10540 return;
10541 };
10542 let order = test_market_buy_order(test_pool().instrument_id);
10543 cache
10544 .borrow_mut()
10545 .add_order(order.clone(), None, None, true)
10546 .unwrap();
10547 let mut receiver = start_with_events(&mut client);
10548 let plan = client
10549 .prepare_swap(&submit_order_cmd(&order), &order)
10550 .unwrap();
10551
10552 execute_swap(
10553 plan,
10554 client.transaction_executor().unwrap(),
10555 client.emitter.clone(),
10556 client.transaction_limits.max_quote_age_blocks,
10557 client.transaction_limits.deadline_seconds,
10558 )
10559 .await
10560 .unwrap();
10561
10562 let events = collect_order_events(&mut receiver);
10563 assert_eq!(events.len(), 2, "was: {events:?}");
10564 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
10565 let OrderEventAny::Filled(fill) = &events[1] else {
10566 panic!("expected OrderFilled, was {:?}", events[1]);
10567 };
10568 let expected_qty = raw_amount_to_quantity(overshoot_base_out, 18).unwrap();
10569 assert_eq!(fill.order_side, OrderSide::Buy);
10570 assert_eq!(fill.last_qty, expected_qty);
10571 assert!(fill.last_qty > order.quantity());
10572 assert_eq!(
10573 Money::from_decimal(
10574 fill.last_qty.as_decimal() * fill.last_px.as_decimal(),
10575 fill.currency,
10576 )
10577 .unwrap(),
10578 Money::from_u256(amount_in, fill.currency).unwrap()
10579 );
10580
10581 drop_execution_schema(&admin_pool, &schema).await;
10582 }
10583
10584 #[tokio::test]
10585 async fn finalized_buy_swap_quarantines_sell_oriented_log() {
10586 let amount_in = expected_buy_amount_in();
10587 let min_amount_out = expected_buy_min_amount_out(50);
10588 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10589 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in)
10590 .with_response(
10591 "eth_getTransactionReceipt",
10592 &finalized_swap_receipt(expected_hash),
10593 );
10594 let Some((admin_pool, schema, mut client, _, cache)) =
10595 swap_client_with_buy_database("execution_submit_buy_sell_log_test", state).await
10596 else {
10597 return;
10598 };
10599 let order = test_market_buy_order(test_pool().instrument_id);
10600 cache
10601 .borrow_mut()
10602 .add_order(order.clone(), None, None, true)
10603 .unwrap();
10604 let mut receiver = start_with_events(&mut client);
10605 let plan = client
10606 .prepare_swap(&submit_order_cmd(&order), &order)
10607 .unwrap();
10608
10609 let error = execute_swap(
10610 plan,
10611 client.transaction_executor().unwrap(),
10612 client.emitter.clone(),
10613 client.transaction_limits.max_quote_age_blocks,
10614 client.transaction_limits.deadline_seconds,
10615 )
10616 .await
10617 .unwrap_err();
10618
10619 let events = collect_order_events(&mut receiver);
10620 assert_swap_quarantined_without_terminal_event(&events);
10621 assert!(
10622 error
10623 .to_string()
10624 .contains("does not match the persisted amount")
10625 || error.to_string().contains("is not a BUY output"),
10626 "was: {error}"
10627 );
10628 assert!(client.in_flight.lock().is_some());
10629
10630 drop_execution_schema(&admin_pool, &schema).await;
10631 }
10632
10633 #[tokio::test]
10634 async fn submit_order_applies_buy_slippage_to_base_output() {
10635 let amount_in = expected_buy_amount_in();
10636 let min_amount_out = expected_buy_min_amount_out(200);
10637 let (expected_hash, expected_raw) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10638 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10639 let Some((admin_pool, schema, mut client, state, cache)) =
10640 swap_client_with_buy_database("execution_submit_buy_slippage_test", state).await
10641 else {
10642 return;
10643 };
10644 let order = test_market_buy_order(test_pool().instrument_id);
10645 cache
10646 .borrow_mut()
10647 .add_order(order.clone(), None, None, true)
10648 .unwrap();
10649 let mut cmd = submit_order_cmd(&order);
10650 cmd.params = Some(serde_json::from_str(r#"{"slippage_bps": 200}"#).unwrap());
10651 let mut receiver = start_with_events(&mut client);
10652
10653 client.submit_order(cmd).unwrap();
10654 await_pending_tasks(&client).await;
10655
10656 let events = collect_order_events(&mut receiver);
10657 assert_swap_submitted_and_filled(&events);
10658 let broadcasts: Vec<_> = state
10659 .recorded_requests()
10660 .into_iter()
10661 .filter(|request| request["method"] == "eth_sendRawTransaction")
10662 .collect();
10663 assert_eq!(broadcasts.len(), 1);
10664 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
10665
10666 drop_execution_schema(&admin_pool, &schema).await;
10667 }
10668
10669 #[tokio::test]
10670 async fn submit_order_denies_buy_on_insufficient_quote_balance() {
10671 let amount_in = expected_buy_amount_in();
10672 let min_amount_out = expected_buy_min_amount_out(50);
10673 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10674 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in)
10675 .with_call_response(BALANCE_OF_SELECTOR, CALL_ZERO);
10676 let Some((admin_pool, schema, mut client, _, cache)) =
10677 swap_client_with_buy_database("execution_submit_buy_balance_test", state).await
10678 else {
10679 return;
10680 };
10681 let order = test_market_buy_order(test_pool().instrument_id);
10682 cache
10683 .borrow_mut()
10684 .add_order(order.clone(), None, None, true)
10685 .unwrap();
10686 let mut receiver = start_with_events(&mut client);
10687
10688 client.submit_order(submit_order_cmd(&order)).unwrap();
10689 await_pending_tasks(&client).await;
10690
10691 let events = collect_order_events(&mut receiver);
10692 assert_eq!(events.len(), 1);
10693 let OrderEventAny::Denied(denied) = &events[0] else {
10694 panic!("expected OrderDenied, was {:?}", events[0]);
10695 };
10696 assert!(
10697 denied.reason.contains("is below the swap amount"),
10698 "was: {}",
10699 denied.reason
10700 );
10701
10702 drop_execution_schema(&admin_pool, &schema).await;
10703 }
10704
10705 #[tokio::test]
10706 async fn finalized_swap_without_log_stays_quarantined() {
10707 let state = swap_rpc_state()
10708 .await
10709 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS);
10710 let Some((admin_pool, schema, mut client, _, _)) =
10711 swap_client_with_database("execution_submit_missing_log_test", state).await
10712 else {
10713 return;
10714 };
10715 let order = test_market_sell_order(test_pool().instrument_id);
10716 let mut receiver = start_with_events(&mut client);
10717
10718 client.submit_order(submit_order_cmd(&order)).unwrap();
10719 await_pending_tasks(&client).await;
10720
10721 let events = collect_order_events(&mut receiver);
10722 let (status, terminal_emitted, active): (String, bool, bool) =
10723 sqlx::query_as(sqlx::AssertSqlSafe(format!(
10724 "SELECT status, terminal_emitted, active FROM {schema}.execution_intent"
10725 )))
10726 .fetch_one(&admin_pool)
10727 .await
10728 .unwrap();
10729
10730 assert_swap_quarantined_without_terminal_event(&events);
10731 assert_eq!(status, "broadcast");
10732 assert!(!terminal_emitted);
10733 assert!(active);
10734 assert!(client.in_flight.lock().is_some());
10735
10736 drop_execution_schema(&admin_pool, &schema).await;
10737 }
10738
10739 #[tokio::test]
10740 async fn finalized_swap_refresh_failure_stays_owned_for_reconciliation() {
10741 let min_amount_out = expected_min_amount_out(50);
10742 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
10743 let state = finalized_swap_rpc_state(expected_hash, min_amount_out).with_response_sequence(
10744 "eth_getBalance",
10745 &[
10746 GET_BALANCE,
10747 GET_BALANCE,
10748 GET_BALANCE,
10749 RPC_METHOD_NOT_FOUND,
10750 RPC_METHOD_NOT_FOUND,
10751 RPC_METHOD_NOT_FOUND,
10752 ],
10753 );
10754 let Some((admin_pool, schema, mut client, _state, _)) =
10755 swap_client_with_database("execution_submit_refresh_fail_test", state).await
10756 else {
10757 return;
10758 };
10759 let order = test_market_sell_order(test_pool().instrument_id);
10760 let mut receiver = start_with_events(&mut client);
10761 let plan = client
10762 .prepare_swap(&submit_order_cmd(&order), &order)
10763 .unwrap();
10764
10765 let error = execute_swap(
10766 plan,
10767 client.transaction_executor().unwrap(),
10768 client.emitter.clone(),
10769 client.transaction_limits.max_quote_age_blocks,
10770 client.transaction_limits.deadline_seconds,
10771 )
10772 .await
10773 .unwrap_err();
10774 let events = collect_order_events(&mut receiver);
10775 let (status, fill_emitted, active): (String, bool, bool) =
10776 sqlx::query_as(sqlx::AssertSqlSafe(format!(
10777 "SELECT status, fill_emitted, active FROM {schema}.execution_intent"
10778 )))
10779 .fetch_one(&admin_pool)
10780 .await
10781 .unwrap();
10782
10783 assert!(
10784 error
10785 .to_string()
10786 .contains("finalized native balance verification is locally invalid"),
10787 "was: {error}"
10788 );
10789 assert_eq!(events.len(), 1, "was: {events:?}");
10790 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
10791 assert_eq!(status, "broadcast");
10792 assert!(!fill_emitted);
10793 assert!(active);
10794 assert!(client.in_flight.lock().is_some());
10795
10796 drop_execution_schema(&admin_pool, &schema).await;
10797 }
10798
10799 #[tokio::test]
10800 async fn submit_order_denies_router_factory_mismatch_before_signing() {
10801 let state = swap_rpc_state()
10802 .await
10803 .with_call_response(FACTORY_SELECTOR, CALL_ZERO);
10804 let Some((admin_pool, schema, mut client, state, _)) =
10805 swap_client_with_database("execution_submit_factory_test", state).await
10806 else {
10807 return;
10808 };
10809 let order = test_market_sell_order(test_pool().instrument_id);
10810 let mut receiver = start_with_events(&mut client);
10811
10812 client.submit_order(submit_order_cmd(&order)).unwrap();
10813 await_pending_tasks(&client).await;
10814
10815 let events = collect_order_events(&mut receiver);
10816 assert_eq!(events.len(), 1);
10817 let OrderEventAny::Denied(denied) = &events[0] else {
10818 panic!("expected OrderDenied, was {:?}", events[0]);
10819 };
10820 assert!(
10821 denied
10822 .reason
10823 .contains("swap deployment manifest verification disagreed"),
10824 "was: {}",
10825 denied.reason
10826 );
10827 let requests = state.recorded_requests();
10828 assert!(
10829 requests
10830 .iter()
10831 .all(|request| request["method"] != "eth_getTransactionCount")
10832 );
10833 assert!(
10834 requests
10835 .iter()
10836 .all(|request| request["method"] != "eth_sendRawTransaction")
10837 );
10838
10839 drop_execution_schema(&admin_pool, &schema).await;
10840 }
10841
10842 #[tokio::test]
10843 async fn submit_order_denies_router_weth_mismatch_before_signing() {
10844 let state = swap_rpc_state()
10845 .await
10846 .with_call_response(WETH9_SELECTOR, CALL_ZERO);
10847 let Some((admin_pool, schema, mut client, state, _)) =
10848 swap_client_with_database("execution_submit_weth_test", state).await
10849 else {
10850 return;
10851 };
10852 let order = test_market_sell_order(test_pool().instrument_id);
10853 let mut receiver = start_with_events(&mut client);
10854
10855 client.submit_order(submit_order_cmd(&order)).unwrap();
10856 await_pending_tasks(&client).await;
10857
10858 let events = collect_order_events(&mut receiver);
10859 assert_eq!(events.len(), 1);
10860 let OrderEventAny::Denied(denied) = &events[0] else {
10861 panic!("expected OrderDenied, was {:?}", events[0]);
10862 };
10863 assert!(
10864 denied
10865 .reason
10866 .contains("swap deployment manifest verification disagreed"),
10867 "was: {}",
10868 denied.reason
10869 );
10870 let requests = state.recorded_requests();
10871 assert!(
10872 requests
10873 .iter()
10874 .all(|request| request["method"] != "eth_getTransactionCount")
10875 );
10876 assert!(
10877 requests
10878 .iter()
10879 .all(|request| request["method"] != "eth_sendRawTransaction")
10880 );
10881
10882 drop_execution_schema(&admin_pool, &schema).await;
10883 }
10884
10885 #[tokio::test]
10886 async fn submit_order_denies_factory_pool_mismatch_before_signing() {
10887 let state = swap_rpc_state()
10888 .await
10889 .with_call_response(GET_POOL_SELECTOR, CALL_ZERO);
10890 let Some((admin_pool, schema, mut client, state, _)) =
10891 swap_client_with_database("execution_submit_pool_identity_test", state).await
10892 else {
10893 return;
10894 };
10895 let order = test_market_sell_order(test_pool().instrument_id);
10896 let mut receiver = start_with_events(&mut client);
10897
10898 client.submit_order(submit_order_cmd(&order)).unwrap();
10899 await_pending_tasks(&client).await;
10900
10901 let events = collect_order_events(&mut receiver);
10902 assert_eq!(events.len(), 1);
10903 let OrderEventAny::Denied(denied) = &events[0] else {
10904 panic!("expected OrderDenied, was {:?}", events[0]);
10905 };
10906 assert!(
10907 denied
10908 .reason
10909 .contains("swap deployment manifest verification disagreed"),
10910 "was: {}",
10911 denied.reason
10912 );
10913 let requests = state.recorded_requests();
10914 assert!(
10915 requests
10916 .iter()
10917 .all(|request| request["method"] != "eth_getTransactionCount")
10918 );
10919 assert!(
10920 requests
10921 .iter()
10922 .all(|request| request["method"] != "eth_sendRawTransaction")
10923 );
10924
10925 drop_execution_schema(&admin_pool, &schema).await;
10926 }
10927
10928 #[tokio::test]
10929 async fn submit_order_denies_cached_token_decimal_mismatch_before_signing() {
10930 let state = swap_rpc_state().await.with_contract_call_response(
10931 WETH,
10932 DECIMALS_SELECTOR,
10933 CALL_DECIMALS_6,
10934 );
10935 let Some((admin_pool, schema, mut client, state, _)) =
10936 swap_client_with_database("execution_submit_decimals_test", state).await
10937 else {
10938 return;
10939 };
10940 let order = test_market_sell_order(test_pool().instrument_id);
10941 let mut receiver = start_with_events(&mut client);
10942
10943 client.submit_order(submit_order_cmd(&order)).unwrap();
10944 await_pending_tasks(&client).await;
10945
10946 let events = collect_order_events(&mut receiver);
10947 assert_eq!(events.len(), 1);
10948 let OrderEventAny::Denied(denied) = &events[0] else {
10949 panic!("expected OrderDenied, was {:?}", events[0]);
10950 };
10951 assert!(
10952 denied
10953 .reason
10954 .contains("swap deployment manifest verification disagreed"),
10955 "was: {}",
10956 denied.reason
10957 );
10958 let requests = state.recorded_requests();
10959 assert!(
10960 requests
10961 .iter()
10962 .all(|request| request["method"] != "eth_getTransactionCount")
10963 );
10964 assert!(
10965 requests
10966 .iter()
10967 .all(|request| request["method"] != "eth_sendRawTransaction")
10968 );
10969
10970 drop_execution_schema(&admin_pool, &schema).await;
10971 }
10972
10973 #[tokio::test]
10974 async fn submit_order_denies_on_insufficient_router_allowance() {
10975 let state = swap_rpc_state()
10976 .await
10977 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO);
10978 let Some((admin_pool, schema, mut client, state, _)) =
10979 swap_client_with_database("execution_submit_allowance_test", state).await
10980 else {
10981 return;
10982 };
10983 let order = test_market_sell_order(test_pool().instrument_id);
10984 let mut receiver = start_with_events(&mut client);
10985
10986 client.submit_order(submit_order_cmd(&order)).unwrap();
10987 await_pending_tasks(&client).await;
10988
10989 let events = collect_order_events(&mut receiver);
10990 assert_eq!(events.len(), 1);
10991 let OrderEventAny::Denied(denied) = &events[0] else {
10992 panic!("expected OrderDenied, was {:?}", events[0]);
10993 };
10994 assert!(
10995 denied.reason.contains("below the swap amount"),
10996 "was: {}",
10997 denied.reason
10998 );
10999 let requests = state.recorded_requests();
11000 assert!(
11001 requests
11002 .iter()
11003 .all(|request| request["method"] != "eth_sendRawTransaction"),
11004 "no broadcast may follow a pre-trade denial"
11005 );
11006 let row_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11007 "SELECT COUNT(*) FROM {schema}.execution_intent"
11008 )))
11009 .fetch_one(&admin_pool)
11010 .await
11011 .unwrap();
11012 assert_eq!(row_count, 0);
11013 assert!(client.in_flight.lock().is_none());
11014
11015 drop_execution_schema(&admin_pool, &schema).await;
11016 }
11017
11018 #[tokio::test]
11019 async fn submit_order_denies_on_insufficient_input_balance() {
11020 let state = swap_rpc_state()
11021 .await
11022 .with_call_response(BALANCE_OF_SELECTOR, CALL_ZERO);
11023 let Some((admin_pool, schema, mut client, _, _)) =
11024 swap_client_with_database("execution_submit_balance_test", state).await
11025 else {
11026 return;
11027 };
11028 let order = test_market_sell_order(test_pool().instrument_id);
11029 let mut receiver = start_with_events(&mut client);
11030
11031 client.submit_order(submit_order_cmd(&order)).unwrap();
11032 await_pending_tasks(&client).await;
11033
11034 let events = collect_order_events(&mut receiver);
11035 assert_eq!(events.len(), 1);
11036 let OrderEventAny::Denied(denied) = &events[0] else {
11037 panic!("expected OrderDenied, was {:?}", events[0]);
11038 };
11039 assert!(
11040 denied.reason.contains("is below the swap amount"),
11041 "was: {}",
11042 denied.reason
11043 );
11044
11045 drop_execution_schema(&admin_pool, &schema).await;
11046 }
11047
11048 #[tokio::test]
11049 async fn submit_order_denies_on_insufficient_native_balance() {
11050 let state = swap_rpc_state()
11051 .await
11052 .with_response("eth_getBalance", GET_BALANCE_INSUFFICIENT);
11053 let Some((admin_pool, schema, mut client, state, _)) =
11054 swap_client_with_database("execution_submit_native_balance_test", state).await
11055 else {
11056 return;
11057 };
11058 let order = test_market_sell_order(test_pool().instrument_id);
11059 let mut receiver = start_with_events(&mut client);
11060
11061 client.submit_order(submit_order_cmd(&order)).unwrap();
11062 await_pending_tasks(&client).await;
11063
11064 let events = collect_order_events(&mut receiver);
11065 assert_eq!(events.len(), 1);
11066 let OrderEventAny::Denied(denied) = &events[0] else {
11067 panic!("expected OrderDenied, was {:?}", events[0]);
11068 };
11069 assert!(
11070 denied.reason.contains("below maximum transaction cost"),
11071 "was: {}",
11072 denied.reason
11073 );
11074 assert!(
11075 state
11076 .recorded_requests()
11077 .iter()
11078 .all(|request| request["method"] != "eth_sendRawTransaction"),
11079 "no broadcast may follow an insufficient native balance"
11080 );
11081 let (row_count, status): (i64, Option<String>) = sqlx::query_as(sqlx::AssertSqlSafe(
11082 format!("SELECT COUNT(*), MAX(status) FROM {schema}.execution_intent"),
11083 ))
11084 .fetch_one(&admin_pool)
11085 .await
11086 .unwrap();
11087 assert_eq!(row_count, 1);
11088 assert_eq!(status.as_deref(), Some("recoverable"));
11089 assert!(client.in_flight.lock().is_none());
11090
11091 drop_execution_schema(&admin_pool, &schema).await;
11092 }
11093
11094 #[tokio::test]
11095 async fn submit_order_denies_on_stale_quote() {
11096 let Some((admin_pool, schema, mut client, state, cache)) =
11097 swap_client_with_database("execution_submit_stale_quote_test", swap_rpc_state().await)
11098 .await
11099 else {
11100 return;
11101 };
11102 let pool = test_pool();
11103 cache
11104 .borrow_mut()
11105 .add_pool_profiler(test_profiler(&pool, FIXTURE_BLOCK - 101))
11106 .unwrap();
11107 let order = test_market_sell_order(pool.instrument_id);
11108 let mut receiver = start_with_events(&mut client);
11109
11110 client.submit_order(submit_order_cmd(&order)).unwrap();
11111 await_pending_tasks(&client).await;
11112
11113 let events = collect_order_events(&mut receiver);
11114 assert_eq!(events.len(), 1);
11115 let OrderEventAny::Denied(denied) = &events[0] else {
11116 panic!("expected OrderDenied, was {:?}", events[0]);
11117 };
11118 assert!(
11119 denied.reason.contains("Stale quote"),
11120 "was: {}",
11121 denied.reason
11122 );
11123 let requests = state.recorded_requests();
11124 assert!(
11125 requests
11126 .iter()
11127 .all(|request| request["method"] != "eth_sendRawTransaction"),
11128 "no broadcast may follow a pre-trade denial"
11129 );
11130
11131 drop_execution_schema(&admin_pool, &schema).await;
11132 }
11133
11134 #[tokio::test]
11135 async fn submit_order_denies_quote_ahead_of_chain_head() {
11136 let Some((admin_pool, schema, mut client, state, cache)) =
11137 swap_client_with_database("execution_submit_ahead_quote_test", swap_rpc_state().await)
11138 .await
11139 else {
11140 return;
11141 };
11142 let pool = test_pool();
11143 cache
11144 .borrow_mut()
11145 .add_pool_profiler(test_profiler(&pool, FIXTURE_BLOCK + 1))
11146 .unwrap();
11147 let order = test_market_sell_order(pool.instrument_id);
11148 let mut receiver = start_with_events(&mut client);
11149
11150 client.submit_order(submit_order_cmd(&order)).unwrap();
11151 await_pending_tasks(&client).await;
11152
11153 let events = collect_order_events(&mut receiver);
11154 assert_eq!(events.len(), 1);
11155 let OrderEventAny::Denied(denied) = &events[0] else {
11156 panic!("expected OrderDenied, was {:?}", events[0]);
11157 };
11158 assert!(
11159 denied.reason.contains("is ahead of the latest block"),
11160 "was: {}",
11161 denied.reason
11162 );
11163 let requests = state.recorded_requests();
11164 assert!(
11165 requests
11166 .iter()
11167 .all(|request| request["method"] != "eth_sendRawTransaction"),
11168 "no broadcast may follow a pre-trade denial"
11169 );
11170
11171 drop_execution_schema(&admin_pool, &schema).await;
11172 }
11173
11174 #[tokio::test]
11175 async fn submit_order_denies_on_deadline_overflow() {
11176 let Some((admin_pool, schema, mut client, state, _)) = swap_client_with_database(
11177 "execution_submit_deadline_overflow_test",
11178 swap_rpc_state().await,
11179 )
11180 .await
11181 else {
11182 return;
11183 };
11184 client.transaction_limits.deadline_seconds = u64::MAX;
11185 let order = test_market_sell_order(test_pool().instrument_id);
11186 let mut receiver = start_with_events(&mut client);
11187
11188 client.submit_order(submit_order_cmd(&order)).unwrap();
11189 await_pending_tasks(&client).await;
11190
11191 let events = collect_order_events(&mut receiver);
11192 assert_eq!(events.len(), 1);
11193 let OrderEventAny::Denied(denied) = &events[0] else {
11194 panic!("expected OrderDenied, was {:?}", events[0]);
11195 };
11196 assert!(
11197 denied.reason.contains("deadline overflow"),
11198 "was: {}",
11199 denied.reason
11200 );
11201 let requests = state.recorded_requests();
11202 assert!(
11203 requests
11204 .iter()
11205 .all(|request| request["method"] != "eth_sendRawTransaction"),
11206 "no broadcast may follow a pre-trade denial"
11207 );
11208 assert!(client.in_flight.lock().is_none());
11209
11210 drop_execution_schema(&admin_pool, &schema).await;
11211 }
11212
11213 #[tokio::test]
11214 async fn submit_order_reconciles_node_rejection_to_finalized_receipt() {
11215 let state = swap_rpc_state()
11216 .await
11217 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_REJECTED);
11218 let Some((admin_pool, schema, mut client, _state, _)) =
11219 swap_client_with_database("execution_submit_node_rejected_test", state).await
11220 else {
11221 return;
11222 };
11223 let order = test_market_sell_order(test_pool().instrument_id);
11224 let mut receiver = start_with_events(&mut client);
11225
11226 client.submit_order(submit_order_cmd(&order)).unwrap();
11227 await_pending_tasks(&client).await;
11228
11229 let events = collect_order_events(&mut receiver);
11230 assert_swap_submitted_and_filled(&events);
11231 let (purpose, status, client_order_id): (String, String, Option<String>) =
11232 sqlx::query_as(sqlx::AssertSqlSafe(format!(
11233 "SELECT purpose, status, client_order_id FROM {schema}.execution_intent"
11234 )))
11235 .fetch_one(&admin_pool)
11236 .await
11237 .unwrap();
11238 assert_eq!(purpose, "swap");
11239 assert_eq!(status, "finalized");
11240 assert_eq!(
11241 client_order_id.as_deref(),
11242 Some(order.client_order_id().as_str())
11243 );
11244 assert!(client.in_flight.lock().is_none());
11245
11246 drop_execution_schema(&admin_pool, &schema).await;
11247 }
11248
11249 #[tokio::test]
11250 async fn submit_order_acknowledges_uncertain_nonce_too_low() {
11251 let state = swap_rpc_state()
11252 .await
11253 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_NONCE_TOO_LOW);
11254 let Some((admin_pool, schema, mut client, _state, _)) =
11255 swap_client_with_database("execution_submit_nonce_too_low_test", state).await
11256 else {
11257 return;
11258 };
11259 let order = test_market_sell_order(test_pool().instrument_id);
11260 let mut receiver = start_with_events(&mut client);
11261
11262 client.submit_order(submit_order_cmd(&order)).unwrap();
11263 await_pending_tasks(&client).await;
11264
11265 let events = collect_order_events(&mut receiver);
11266 assert_swap_submitted_and_filled(&events);
11267 let (purpose, status, client_order_id): (String, String, Option<String>) =
11268 sqlx::query_as(sqlx::AssertSqlSafe(format!(
11269 "SELECT purpose, status, client_order_id FROM {schema}.execution_intent"
11270 )))
11271 .fetch_one(&admin_pool)
11272 .await
11273 .unwrap();
11274 assert_eq!(purpose, "swap");
11275 assert_eq!(status, "finalized");
11276 assert_eq!(
11277 client_order_id.as_deref(),
11278 Some(order.client_order_id().as_str())
11279 );
11280 assert!(client.in_flight.lock().is_none());
11281
11282 drop_execution_schema(&admin_pool, &schema).await;
11283 }
11284
11285 #[tokio::test]
11286 async fn submit_order_rejected_on_reverted_receipt() {
11287 let expected_min_out = expected_min_amount_out(50);
11288 let (expected_hash, _) = expected_swap_tx(expected_min_out).await;
11289 let block = finalized_swap_block(expected_hash, expected_min_out);
11290 let receipt = receipt_with_transaction_hash(RECEIPT_REVERTED, expected_hash);
11291 let state = with_finalized_identity(
11292 swap_rpc_state()
11293 .await
11294 .with_response("eth_getTransactionReceipt", &receipt),
11295 &block,
11296 &receipt,
11297 );
11298 let Some((admin_pool, schema, mut client, _state, _)) =
11299 swap_client_with_database("execution_submit_reverted_test", state).await
11300 else {
11301 return;
11302 };
11303 let order = test_market_sell_order(test_pool().instrument_id);
11304 let mut receiver = start_with_events(&mut client);
11305 client.submit_order(submit_order_cmd(&order)).unwrap();
11306 await_pending_tasks(&client).await;
11307
11308 let events = collect_order_events(&mut receiver);
11309 assert_eq!(events.len(), 2);
11310 assert!(
11311 matches!(&events[0], OrderEventAny::Submitted(_)),
11312 "was: {:?}",
11313 events[0]
11314 );
11315 let OrderEventAny::Rejected(rejected) = &events[1] else {
11316 panic!("expected OrderRejected, was {:?}", events[1]);
11317 };
11318 assert!(
11319 rejected.reason.contains("reverted on-chain"),
11320 "was: {}",
11321 rejected.reason
11322 );
11323
11324 let record = client
11325 .cache
11326 .get_execution_transaction(42161, &expected_hash.to_string())
11327 .await
11328 .unwrap()
11329 .unwrap();
11330 assert_eq!(record.purpose, "swap");
11331 assert_eq!(record.status, "reverted");
11332 assert_eq!(
11333 record.client_order_id.as_deref(),
11334 Some(order.client_order_id().as_str())
11335 );
11336 assert!(client.in_flight.lock().is_none());
11337
11338 drop_execution_schema(&admin_pool, &schema).await;
11339 }
11340
11341 #[tokio::test]
11342 async fn submit_order_acknowledges_ambiguous_broadcast() {
11343 let state = swap_rpc_state()
11346 .await
11347 .with_response("eth_sendRawTransaction", "not json");
11348 let Some((admin_pool, schema, mut client, _state, _)) =
11349 swap_client_with_database("execution_submit_ambiguous_test", state).await
11350 else {
11351 return;
11352 };
11353 let order = test_market_sell_order(test_pool().instrument_id);
11354 let mut receiver = start_with_events(&mut client);
11355
11356 client.submit_order(submit_order_cmd(&order)).unwrap();
11357 await_pending_tasks(&client).await;
11358
11359 let events = collect_order_events(&mut receiver);
11360 assert_swap_submitted_and_filled(&events);
11361
11362 let record = client
11363 .cache
11364 .get_execution_transaction(
11365 42161,
11366 &expected_swap_tx(expected_min_amount_out(50))
11367 .await
11368 .0
11369 .to_string(),
11370 )
11371 .await
11372 .unwrap()
11373 .unwrap();
11374 assert_eq!(record.purpose, "swap");
11375 assert_eq!(record.status, "finalized");
11376 assert!(client.in_flight.lock().is_none());
11377
11378 drop_execution_schema(&admin_pool, &schema).await;
11379 }
11380
11381 #[tokio::test]
11382 async fn submit_order_acknowledges_broadcast_hash_mismatch() {
11383 let state = swap_rpc_state_for_mismatch().await;
11386 let Some((admin_pool, schema, mut client, _state, _)) =
11387 swap_client_with_database("execution_submit_hash_mismatch_test", state).await
11388 else {
11389 return;
11390 };
11391 let order = test_market_sell_order(test_pool().instrument_id);
11392 let mut receiver = start_with_events(&mut client);
11393
11394 client.submit_order(submit_order_cmd(&order)).unwrap();
11395 await_pending_tasks(&client).await;
11396
11397 let events = collect_order_events(&mut receiver);
11398 assert_swap_submitted_and_filled(&events);
11399
11400 let record = client
11401 .cache
11402 .get_execution_transaction(
11403 42161,
11404 &expected_swap_tx(expected_min_amount_out(50))
11405 .await
11406 .0
11407 .to_string(),
11408 )
11409 .await
11410 .unwrap()
11411 .unwrap();
11412 assert_eq!(record.purpose, "swap");
11413 assert_eq!(record.status, "finalized");
11414 assert!(client.in_flight.lock().is_none());
11415
11416 drop_execution_schema(&admin_pool, &schema).await;
11417 }
11418
11419 #[tokio::test]
11420 async fn submit_order_persists_authenticated_envelope_before_broadcast() {
11421 let state = swap_rpc_state().await;
11422 let Some((admin_pool, schema, mut client, state, _)) =
11423 swap_client_with_database("protected_submit_test", state).await
11424 else {
11425 return;
11426 };
11427 let order = test_market_sell_order(test_pool().instrument_id);
11428 let mut receiver = start_with_events(&mut client);
11429
11430 client.submit_order(submit_order_cmd(&order)).unwrap();
11431 await_pending_tasks(&client).await;
11432
11433 let events = collect_order_events(&mut receiver);
11434 assert_swap_submitted_and_filled(&events);
11435 let representations: Vec<(bool, bool)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
11436 "SELECT raw_transaction IS NULL, sealed_transaction IS NOT NULL \
11437 FROM {schema}.execution_transaction_hash WHERE payload_expected"
11438 )))
11439 .fetch_all(&admin_pool)
11440 .await
11441 .unwrap();
11442 let broadcasts = state
11443 .recorded_requests()
11444 .into_iter()
11445 .filter(|request| request["method"] == "eth_sendRawTransaction")
11446 .count();
11447 assert_eq!(representations, vec![(true, true)]);
11448 assert_eq!(broadcasts, 1);
11449
11450 drop_execution_schema(&admin_pool, &schema).await;
11451 }
11452
11453 #[tokio::test]
11454 async fn protected_persistence_failure_prevents_broadcast_and_acknowledgment() {
11455 let state = swap_rpc_state().await;
11456 let Some((admin_pool, schema, mut client, state, _)) =
11457 swap_client_with_database("protected_persist_failure_test", state).await
11458 else {
11459 return;
11460 };
11461
11462 for statement in [
11463 format!(
11464 "CREATE FUNCTION {schema}.reject_protected_payload() RETURNS trigger \
11465 LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''test payload rejection''; END'"
11466 ),
11467 format!(
11468 "CREATE TRIGGER reject_protected_payload BEFORE INSERT ON \
11469 {schema}.execution_transaction_hash FOR EACH ROW \
11470 EXECUTE FUNCTION {schema}.reject_protected_payload()"
11471 ),
11472 ] {
11473 sqlx::query(sqlx::AssertSqlSafe(statement))
11474 .execute(&admin_pool)
11475 .await
11476 .unwrap();
11477 }
11478 let order = test_market_sell_order(test_pool().instrument_id);
11479 let mut receiver = start_with_events(&mut client);
11480
11481 client.submit_order(submit_order_cmd(&order)).unwrap();
11482 await_pending_tasks(&client).await;
11483
11484 let events = collect_order_events(&mut receiver);
11485 assert!(
11486 events
11487 .iter()
11488 .all(|event| !matches!(event, OrderEventAny::Submitted(_)))
11489 );
11490 let broadcasts = state
11491 .recorded_requests()
11492 .into_iter()
11493 .filter(|request| request["method"] == "eth_sendRawTransaction")
11494 .count();
11495 let payload_rows: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11496 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
11497 )))
11498 .fetch_one(&admin_pool)
11499 .await
11500 .unwrap();
11501 assert_eq!(broadcasts, 0);
11502 assert_eq!(payload_rows, 0);
11503
11504 drop_execution_schema(&admin_pool, &schema).await;
11505 }
11506
11507 #[tokio::test]
11508 async fn submit_order_reservation_failure_denies_without_broadcast_and_releases_slot() {
11509 let Some((admin_pool, schema, mut client, state, cache)) =
11510 swap_client_with_database("execution_submit_persist_fail_test", swap_rpc_state().await)
11511 .await
11512 else {
11513 return;
11514 };
11515 sqlx::query(sqlx::AssertSqlSafe(format!(
11516 "DROP TABLE {schema}.execution_intent CASCADE"
11517 )))
11518 .execute(&admin_pool)
11519 .await
11520 .unwrap();
11521 let pool = test_pool();
11522 let first = test_market_sell_order(pool.instrument_id);
11523 let second = market_sell_order_with_id(pool.instrument_id, "O-SWAP-002");
11524 cache
11525 .borrow_mut()
11526 .add_order(second.clone(), None, None, false)
11527 .unwrap();
11528 let mut receiver = start_with_events(&mut client);
11529
11530 client.submit_order(submit_order_cmd(&first)).unwrap();
11531 await_pending_tasks(&client).await;
11532
11533 let events = collect_order_events(&mut receiver);
11534 assert_eq!(events.len(), 1);
11535 let OrderEventAny::Denied(denied) = &events[0] else {
11536 panic!("expected OrderDenied, was {:?}", events[0]);
11537 };
11538 assert!(
11539 denied
11540 .reason
11541 .contains("Execution intent reservation failed before commit"),
11542 "was: {}",
11543 denied.reason
11544 );
11545 client.submit_order(submit_order_cmd(&second)).unwrap();
11546 await_pending_tasks(&client).await;
11547 let retry_events = collect_order_events(&mut receiver);
11548 assert_eq!(retry_events.len(), 1);
11549 let OrderEventAny::Denied(retry_denied) = &retry_events[0] else {
11550 panic!("expected OrderDenied, was {:?}", retry_events[0]);
11551 };
11552 assert_eq!(
11553 retry_denied.reason,
11554 "Execution intent reservation failed before commit"
11555 );
11556 let broadcasts = state
11557 .recorded_requests()
11558 .into_iter()
11559 .filter(|request| request["method"] == "eth_sendRawTransaction")
11560 .count();
11561 assert_eq!(broadcasts, 0);
11562 assert!(client.in_flight.lock().is_none());
11563
11564 drop_execution_schema(&admin_pool, &schema).await;
11565 }
11566
11567 #[tokio::test]
11568 async fn submit_order_reservation_commit_failure_keeps_preparing_slot() {
11569 let Some((admin_pool, schema, mut client, state, cache)) = swap_client_with_database(
11570 "execution_submit_reservation_commit_fail_test",
11571 swap_rpc_state().await,
11572 )
11573 .await
11574 else {
11575 return;
11576 };
11577 install_reservation_commit_rejection(&admin_pool, &schema).await;
11578 let pool = test_pool();
11579 let first = test_market_sell_order(pool.instrument_id);
11580 let second = market_sell_order_with_id(pool.instrument_id, "O-SWAP-002");
11581 cache
11582 .borrow_mut()
11583 .add_order(second.clone(), None, None, false)
11584 .unwrap();
11585 let mut receiver = start_with_events(&mut client);
11586
11587 client.submit_order(submit_order_cmd(&first)).unwrap();
11588 await_pending_tasks(&client).await;
11589 client.submit_order(submit_order_cmd(&second)).unwrap();
11590 await_pending_tasks(&client).await;
11591
11592 let events = collect_order_events(&mut receiver);
11593 let submitted = events
11594 .iter()
11595 .filter(|event| matches!(event, OrderEventAny::Submitted(_)))
11596 .count();
11597 let denied_commit = events
11598 .iter()
11599 .filter(|event| {
11600 matches!(event, OrderEventAny::Denied(denied) if denied.reason == "Execution intent reservation commit outcome is unknown; reconciliation is required")
11601 })
11602 .count();
11603 let denied_in_flight = events
11604 .iter()
11605 .filter(|event| {
11606 matches!(event, OrderEventAny::Denied(denied) if denied.reason.contains("at most one transaction can be in flight"))
11607 })
11608 .count();
11609 let requests = state.recorded_requests();
11610 let intent_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11611 "SELECT COUNT(*) FROM {schema}.execution_intent"
11612 )))
11613 .fetch_one(&admin_pool)
11614 .await
11615 .unwrap();
11616 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11617 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
11618 )))
11619 .fetch_one(&admin_pool)
11620 .await
11621 .unwrap();
11622
11623 assert_eq!(submitted, 0, "was: {events:?}");
11624 assert_eq!(denied_commit, 1, "was: {events:?}");
11625 assert_eq!(denied_in_flight, 1, "was: {events:?}");
11626 assert!(matches!(
11627 *client.in_flight.lock(),
11628 Some(InFlightSlot::Preparing(TransactionPurpose::Swap))
11629 ));
11630 assert_eq!(intent_count, 0);
11631 assert_eq!(signed_count, 0);
11632 assert!(
11633 requests
11634 .iter()
11635 .all(|request| request["method"] != "eth_getTransactionCount")
11636 );
11637 assert!(
11638 requests
11639 .iter()
11640 .all(|request| request["method"] != "eth_sendRawTransaction")
11641 );
11642
11643 drop_execution_schema(&admin_pool, &schema).await;
11644 }
11645
11646 #[tokio::test]
11647 async fn protected_payload_migration_failure_keeps_plaintext_and_blocks_ready() {
11648 let Some((admin_pool, schema, client, _)) = execution_client_with_unprotected_database(
11649 "protected_payload_migration_failure",
11650 execution_rpc_state(),
11651 )
11652 .await
11653 else {
11654 return;
11655 };
11656 let database = client.cache.database.as_ref().unwrap();
11657 let (intent, _) = persist_invalid_test_swap(database, None).await;
11658 let keys = payload_test_keys([0x31; 32], vec![], "migration-failure");
11659 let error = database
11660 .ensure_execution_payload_storage(&keys)
11661 .await
11662 .unwrap_err();
11663
11664 assert!(
11665 error
11666 .to_string()
11667 .contains("failed to authenticate execution payload")
11668 );
11669 let row = database
11670 .get_execution_transaction_hashes(intent.id)
11671 .await
11672 .unwrap()
11673 .pop()
11674 .unwrap();
11675 let operation: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11676 "SELECT operation FROM {schema}.execution_payload_state"
11677 )))
11678 .fetch_one(&admin_pool)
11679 .await
11680 .unwrap();
11681 assert_eq!(operation, "migrate");
11682 assert!(row.raw_transaction.is_some());
11683 assert!(row.sealed_transaction.is_none());
11684
11685 drop_execution_schema(&admin_pool, &schema).await;
11686 }
11687
11688 #[tokio::test]
11689 async fn protected_payload_migration_restart_restore_rewrap_and_rollback() {
11690 let Some((admin_pool, pg_config)) =
11691 connect_test_postgres("protected payload lifecycle").await
11692 else {
11693 return;
11694 };
11695 let schema = format!("protected_payload_lifecycle_{}", std::process::id());
11696 setup_execution_schema(&admin_pool, &schema).await;
11697 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
11698 let options = options.options([("search_path", schema.clone())]);
11699 let database = connect_test_database(options.clone()).await.unwrap();
11700 database
11701 .ensure_execution_transaction_schema()
11702 .await
11703 .unwrap();
11704 let intent = reserve_test_wrap_intent(&database).await;
11705 database
11706 .assign_execution_intent_nonce(intent.id, 7)
11707 .await
11708 .unwrap();
11709 let transaction = build_eip1559_transaction(
11710 42161,
11711 7,
11712 78_000,
11713 130_000_000,
11714 10_000_000,
11715 WETH_ADDRESS,
11716 U256::from(1_u64),
11717 Bytes::from(hex::decode("d0e30db0").unwrap()),
11718 );
11719 let (transaction_hash, raw_transaction) = sign_eip1559_transaction(
11720 transaction,
11721 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
11722 )
11723 .await
11724 .unwrap();
11725 database
11726 .add_execution_transaction_hash(
11727 intent.id,
11728 42161,
11729 &transaction_hash.to_string(),
11730 &raw_transaction,
11731 )
11732 .await
11733 .unwrap();
11734 let keys = payload_test_keys([0x41; 32], vec![], "restore-a");
11735
11736 database
11737 .ensure_execution_payload_storage(&keys)
11738 .await
11739 .unwrap();
11740 let operation: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11741 "SELECT operation FROM {schema}.execution_payload_state"
11742 )))
11743 .fetch_one(&admin_pool)
11744 .await
11745 .unwrap();
11746 assert_eq!(operation, "ready");
11747 drop(database);
11748
11749 let restarted = connect_test_database(options).await.unwrap();
11750 restarted
11751 .ensure_execution_payload_storage(&keys)
11752 .await
11753 .unwrap();
11754 let row = restarted
11755 .get_execution_transaction_hashes(intent.id)
11756 .await
11757 .unwrap()
11758 .pop()
11759 .unwrap();
11760 assert!(row.raw_transaction.is_none());
11761 let sealed_transaction = row.sealed_transaction.clone().unwrap();
11762 let stored_intent = restarted.get_execution_intent(intent.id).await.unwrap();
11763 let context = payload_context(&stored_intent, &row, keys.deployment_id()).unwrap();
11764 let alternate_envelope = keys.seal(&raw_transaction, &context).unwrap();
11765 assert_ne!(alternate_envelope, sealed_transaction);
11766 let repeated = restarted
11767 .add_execution_transaction_envelope(
11768 intent.id,
11769 42161,
11770 &transaction_hash.to_string(),
11771 &alternate_envelope,
11772 )
11773 .await
11774 .unwrap();
11775 let check = restarted
11776 .check_execution_payload_storage(Some(&keys), None, 1)
11777 .await
11778 .unwrap();
11779 assert_eq!(
11780 repeated.sealed_transaction,
11781 Some(sealed_transaction.clone())
11782 );
11783 assert_eq!(check.plaintext_rows, 0);
11784 assert_eq!(check.original_rows, 1);
11785 assert_eq!(check.replacement_rows, 0);
11786 assert_eq!(check.authenticated_rows, 1);
11787 assert!(!check.read_roles.is_empty());
11788
11789 sqlx::query(sqlx::AssertSqlSafe(format!(
11790 "UPDATE {schema}.execution_payload_key_state SET seals = 4294967295"
11791 )))
11792 .execute(&admin_pool)
11793 .await
11794 .unwrap();
11795 let exhausted = restarted
11796 .reserve_execution_payload_seal(&keys)
11797 .await
11798 .unwrap_err();
11799 assert!(exhausted.to_string().contains("seal limit"));
11800 let seals: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11801 "SELECT seals FROM {schema}.execution_payload_key_state"
11802 )))
11803 .fetch_one(&admin_pool)
11804 .await
11805 .unwrap();
11806 assert_eq!(seals, 4_294_967_295);
11807 sqlx::query(sqlx::AssertSqlSafe(format!(
11808 "UPDATE {schema}.execution_payload_key_state SET seals = 1"
11809 )))
11810 .execute(&admin_pool)
11811 .await
11812 .unwrap();
11813
11814 let missing_key = restarted
11815 .check_execution_payload_storage(None, None, 1)
11816 .await
11817 .unwrap_err();
11818 assert!(
11819 missing_key
11820 .to_string()
11821 .contains("no payload key is configured")
11822 );
11823 let restored_elsewhere = payload_test_keys([0x41; 32], vec![], "restore-b");
11824 let wrong_context = restarted
11825 .ensure_execution_payload_storage(&restored_elsewhere)
11826 .await
11827 .unwrap_err();
11828 assert!(wrong_context.to_string().contains("deployment ID"));
11829
11830 sqlx::query(sqlx::AssertSqlSafe(format!(
11831 "UPDATE {schema}.execution_transaction_hash \
11832 SET sealed_transaction = set_byte(sealed_transaction, octet_length(sealed_transaction) - 1, \
11833 get_byte(sealed_transaction, octet_length(sealed_transaction) - 1) # 1)"
11834 )))
11835 .execute(&admin_pool)
11836 .await
11837 .unwrap();
11838 assert!(
11839 restarted
11840 .check_execution_payload_storage(Some(&keys), None, 1)
11841 .await
11842 .is_err()
11843 );
11844 sqlx::query(sqlx::AssertSqlSafe(format!(
11845 "UPDATE {schema}.execution_transaction_hash SET sealed_transaction = $1"
11846 )))
11847 .bind(&sealed_transaction)
11848 .execute(&admin_pool)
11849 .await
11850 .unwrap();
11851
11852 for statement in [
11853 format!(
11854 "ALTER TABLE {schema}.execution_transaction_hash \
11855 DROP CONSTRAINT execution_transaction_payload_protected_check"
11856 ),
11857 format!("UPDATE {schema}.execution_transaction_hash SET sealed_transaction = NULL"),
11858 ] {
11859 sqlx::query(sqlx::AssertSqlSafe(statement))
11860 .execute(&admin_pool)
11861 .await
11862 .unwrap();
11863 }
11864 assert!(
11865 restarted
11866 .check_execution_payload_storage(Some(&keys), None, 1)
11867 .await
11868 .is_err()
11869 );
11870 sqlx::query(sqlx::AssertSqlSafe(format!(
11871 "UPDATE {schema}.execution_transaction_hash SET sealed_transaction = $1"
11872 )))
11873 .bind(&sealed_transaction)
11874 .execute(&admin_pool)
11875 .await
11876 .unwrap();
11877 sqlx::query(sqlx::AssertSqlSafe(format!(
11878 "ALTER TABLE {schema}.execution_transaction_hash \
11879 ADD CONSTRAINT execution_transaction_payload_protected_check CHECK ( \
11880 (payload_expected AND raw_transaction IS NULL AND sealed_transaction IS NOT NULL) \
11881 OR (NOT payload_expected AND raw_transaction IS NULL AND sealed_transaction IS NULL) \
11882 )"
11883 )))
11884 .execute(&admin_pool)
11885 .await
11886 .unwrap();
11887
11888 let rotated = payload_test_keys([0x52; 32], vec![[0x41; 32]], "restore-a");
11889 restarted
11890 .rewrap_execution_payload_storage(&rotated, 1)
11891 .await
11892 .unwrap();
11893 let rotated_check = restarted
11894 .check_execution_payload_storage(Some(&rotated), None, 1)
11895 .await
11896 .unwrap();
11897 assert_eq!(rotated_check.authenticated_rows, 1);
11898 assert_eq!(rotated_check.key_ids.len(), 1);
11899
11900 restarted
11901 .rollback_execution_payload_storage(&rotated, 1)
11902 .await
11903 .unwrap();
11904 let rolled_back = restarted
11905 .get_execution_transaction_hashes(intent.id)
11906 .await
11907 .unwrap()
11908 .pop()
11909 .unwrap();
11910 assert_eq!(
11911 rolled_back.raw_transaction.as_deref(),
11912 Some(raw_transaction.as_slice())
11913 );
11914 assert!(rolled_back.sealed_transaction.is_none());
11915 let legacy_check = restarted
11916 .check_execution_payload_storage(None, None, 1)
11917 .await
11918 .unwrap();
11919 assert!(!legacy_check.protected);
11920 assert_eq!(legacy_check.plaintext_rows, 1);
11921 assert_eq!(legacy_check.authenticated_rows, 1);
11922
11923 drop_execution_schema(&admin_pool, &schema).await;
11924 }
11925
11926 #[tokio::test]
11927 async fn protected_payload_storage_authenticates_multiple_execution_identities() {
11928 let Some((admin_pool, pg_config)) =
11929 connect_test_postgres("protected payload multiple identities").await
11930 else {
11931 return;
11932 };
11933 let schema = format!("protected_payload_identities_{}", std::process::id());
11934 setup_execution_schema(&admin_pool, &schema).await;
11935 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
11936 let database = connect_test_database(options.options([("search_path", schema.clone())]))
11937 .await
11938 .unwrap();
11939 database
11940 .ensure_execution_transaction_schema()
11941 .await
11942 .unwrap();
11943
11944 let private_keys = [
11945 TEST_PRIVATE_KEY,
11946 "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
11947 ];
11948
11949 for (index, private_key) in private_keys.into_iter().enumerate() {
11950 let signer = PrivateKeySigner::from_str(private_key).unwrap();
11951 let nonce = 7 + u64::try_from(index).unwrap();
11952 let intent =
11953 reserve_test_wrap_intent_for_wallet(&database, &signer.address().to_string()).await;
11954 database
11955 .assign_execution_intent_nonce(intent.id, nonce)
11956 .await
11957 .unwrap();
11958 let transaction = build_eip1559_transaction(
11959 42161,
11960 nonce,
11961 78_000,
11962 130_000_000,
11963 10_000_000,
11964 WETH_ADDRESS,
11965 U256::from(1_u64),
11966 Bytes::from(hex::decode("d0e30db0").unwrap()),
11967 );
11968 let (transaction_hash, raw_transaction) =
11969 sign_eip1559_transaction(transaction, &signer)
11970 .await
11971 .unwrap();
11972 database
11973 .add_execution_transaction_hash(
11974 intent.id,
11975 42161,
11976 &transaction_hash.to_string(),
11977 &raw_transaction,
11978 )
11979 .await
11980 .unwrap();
11981 }
11982
11983 let keys = payload_test_keys([0x61; 32], vec![], "multiple-identities");
11984 database
11985 .ensure_execution_payload_storage(&keys)
11986 .await
11987 .unwrap();
11988 let lease = database
11989 .require_execution_payload_storage(
11990 &keys,
11991 PayloadPolicy {
11992 chain_id: 42161,
11993 signer: Address::from_str(WALLET).unwrap(),
11994 gas_limit: 1_000_000,
11995 max_fee_per_gas: 1_000_000_000,
11996 },
11997 1,
11998 )
11999 .await
12000 .unwrap();
12001 drop(lease);
12002 let check = database
12003 .check_execution_payload_storage(Some(&keys), None, 1)
12004 .await
12005 .unwrap();
12006
12007 assert!(check.protected);
12008 assert_eq!(check.plaintext_rows, 0);
12009 assert_eq!(check.original_rows, 2);
12010 assert_eq!(check.replacement_rows, 0);
12011 assert_eq!(check.authenticated_rows, 2);
12012 assert_eq!(check.key_ids.len(), 1);
12013
12014 drop_execution_schema(&admin_pool, &schema).await;
12015 }
12016
12017 #[rstest]
12018 #[case::finalized(TransactionStatus::Finalized)]
12019 #[case::reverted(TransactionStatus::Reverted)]
12020 #[tokio::test]
12021 async fn protected_payload_storage_ignores_current_policy_for_released_terminal_history(
12022 #[case] status: TransactionStatus,
12023 ) {
12024 let Some((admin_pool, pg_config)) =
12025 connect_test_postgres("protected payload terminal history").await
12026 else {
12027 return;
12028 };
12029 let schema = format!(
12030 "protected_payload_terminal_{}_{}",
12031 status.as_str(),
12032 std::process::id()
12033 );
12034 setup_execution_schema(&admin_pool, &schema).await;
12035 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
12036 let database = connect_test_database(options.options([("search_path", schema.clone())]))
12037 .await
12038 .unwrap();
12039 database
12040 .ensure_execution_transaction_schema()
12041 .await
12042 .unwrap();
12043 let (intent, transaction_hash, _) = persist_test_wrap_broadcast(&database, None).await;
12044 database
12045 .record_execution_status(
12046 intent.id,
12047 &transaction_hash.to_string(),
12048 status,
12049 None,
12050 None,
12051 None,
12052 None,
12053 None,
12054 )
12055 .await
12056 .unwrap();
12057 database
12058 .mark_execution_event_emitted(intent.id, "terminal")
12059 .await
12060 .unwrap();
12061 let keys = payload_test_keys([0x62; 32], vec![], "terminal-history");
12062 database
12063 .ensure_execution_payload_storage(&keys)
12064 .await
12065 .unwrap();
12066
12067 let lease = database
12068 .require_execution_payload_storage(
12069 &keys,
12070 PayloadPolicy {
12071 chain_id: 42161,
12072 signer: Address::from_str(WALLET).unwrap(),
12073 gas_limit: 1,
12074 max_fee_per_gas: 1,
12075 },
12076 1,
12077 )
12078 .await
12079 .unwrap();
12080 drop(lease);
12081 let check = database
12082 .check_execution_payload_storage(Some(&keys), None, 1)
12083 .await
12084 .unwrap();
12085 let active_intent = reserve_test_wrap_intent(&database).await;
12086 database
12087 .assign_execution_intent_nonce(active_intent.id, 8)
12088 .await
12089 .unwrap();
12090 let active_intent = database
12091 .get_execution_intent(active_intent.id)
12092 .await
12093 .unwrap();
12094 let active_transaction = build_eip1559_transaction(
12095 42161,
12096 8,
12097 78_000,
12098 130_000_000,
12099 10_000_000,
12100 WETH_ADDRESS,
12101 U256::from(1_u64),
12102 Bytes::from(hex::decode("d0e30db0").unwrap()),
12103 );
12104 let (active_hash, active_raw_transaction) = sign_eip1559_transaction(
12105 active_transaction,
12106 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
12107 )
12108 .await
12109 .unwrap();
12110 persist_test_payload(
12111 &database,
12112 Some(&keys),
12113 &active_intent,
12114 active_hash,
12115 &active_raw_transaction,
12116 )
12117 .await;
12118 let active_error = match database
12119 .require_execution_payload_storage(
12120 &keys,
12121 PayloadPolicy {
12122 chain_id: 42161,
12123 signer: Address::from_str(WALLET).unwrap(),
12124 gas_limit: 1,
12125 max_fee_per_gas: 1,
12126 },
12127 1,
12128 )
12129 .await
12130 {
12131 Ok(_) => panic!("active execution payload unexpectedly passed current policy"),
12132 Err(e) => e,
12133 };
12134 let active_error = format!("{active_error:#}");
12135
12136 assert!(check.protected);
12137 assert_eq!(check.authenticated_rows, 1);
12138 assert!(
12139 active_error.contains(&format!(
12140 "execution intent {} transaction {active_hash} violates current execution policy",
12141 active_intent.id
12142 )),
12143 "was: {active_error}"
12144 );
12145 assert!(
12146 active_error.contains("gas limit 78000 exceeds configured ceiling 1"),
12147 "was: {active_error}"
12148 );
12149
12150 drop_execution_schema(&admin_pool, &schema).await;
12151 }
12152
12153 #[allow(unsafe_code)] #[tokio::test]
12155 async fn rollback_blocks_execution_until_protection_and_full_check_succeed() {
12156 let state = ready_rpc_state();
12157 let Some((admin_pool, schema, mut client, state)) =
12158 execution_client_with_database("payload_rollback_reactivation", state).await
12159 else {
12160 return;
12161 };
12162 unsafe { std::env::set_var("BLOCKCHAIN_TEST_PRIVATE_KEY", TEST_PRIVATE_KEY) };
12164 client.disconnect().await.unwrap();
12165 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
12166 replace_exec_event_sender(sender);
12167 client.start().unwrap();
12168
12169 client.rollback_payload_storage(1).await.unwrap();
12170 let unprotected = client.check_payload_storage(1).await.unwrap();
12171 let error = client.connect().await.unwrap_err();
12172
12173 assert!(!unprotected.protected);
12174 assert_eq!(unprotected.plaintext_rows, 0);
12175 assert_eq!(unprotected.authenticated_rows, 0);
12176 assert!(
12177 error
12178 .to_string()
12179 .contains("Postgres execution requires protected payload storage"),
12180 "was: {error}"
12181 );
12182 assert!(client.signer.is_none());
12183 assert!(state.recorded_requests().is_empty());
12184
12185 client.protect_payload_storage().await.unwrap();
12186 let protected = client.check_payload_storage(1).await.unwrap();
12187 assert!(protected.protected);
12188 assert_eq!(protected.plaintext_rows, 0);
12189 assert_eq!(protected.authenticated_rows, 0);
12190
12191 client.connect().await.unwrap();
12192
12193 assert!(client.is_connected());
12194 assert!(client.transaction_executor().is_ok());
12195 assert!(!state.recorded_requests().is_empty());
12196
12197 drop(client);
12198 drop_execution_schema(&admin_pool, &schema).await;
12199 }
12200
12201 #[rstest]
12202 #[case::active_key(true)]
12203 #[case::deployment_identity(false)]
12204 #[tokio::test]
12205 async fn postgres_connect_rejects_missing_payload_identity_before_rpc(
12206 #[case] remove_active_key: bool,
12207 ) {
12208 let test_name = if remove_active_key {
12209 "payload_connect_missing_active_key"
12210 } else {
12211 "payload_connect_missing_deployment"
12212 };
12213 let Some((admin_pool, schema, mut client, state)) =
12214 execution_client_with_database(test_name, ready_rpc_state()).await
12215 else {
12216 return;
12217 };
12218 client.disconnect().await.unwrap();
12219 client.payload_keys = None;
12220 if remove_active_key {
12221 client.config.payload_key_env = None;
12222 client.config.payload_deployment_id = None;
12223 } else {
12224 client.config.payload_deployment_id = None;
12225 }
12226
12227 let error = client.connect().await.unwrap_err();
12228
12229 assert!(
12230 error.to_string().contains(if remove_active_key {
12231 "Postgres execution requires an active payload key and deployment identity"
12232 } else {
12233 "Payload deployment ID is required"
12234 }),
12235 "was: {error}"
12236 );
12237 assert!(client.signer.is_none());
12238 assert!(client.payload_keys.is_none());
12239 assert!(state.recorded_requests().is_empty());
12240
12241 drop(client);
12242 drop_execution_schema(&admin_pool, &schema).await;
12243 }
12244
12245 #[tokio::test]
12246 async fn postgres_connect_rejects_missing_envelope_key_before_rpc() {
12247 let Some((admin_pool, schema, mut client, state)) = execution_client_with_database(
12248 "payload_connect_missing_envelope_key",
12249 ready_rpc_state(),
12250 )
12251 .await
12252 else {
12253 return;
12254 };
12255 let database = client.cache.database.as_ref().unwrap().clone();
12256 persist_test_wrap_broadcast(&database, client.payload_keys.as_deref()).await;
12257 client.disconnect().await.unwrap();
12258 client.payload_keys = None;
12259 let keys = set_test_payload_key(&mut client, [0xb6; 32], &schema);
12260 sqlx::query(sqlx::AssertSqlSafe(format!(
12261 "UPDATE {schema}.execution_payload_state SET active_key_id = $1 \
12262 WHERE component = 'signed_transactions'"
12263 )))
12264 .bind(keys.active_key_id().as_slice())
12265 .execute(&admin_pool)
12266 .await
12267 .unwrap();
12268
12269 let error = client.connect().await.unwrap_err();
12270
12271 assert!(
12272 error
12273 .to_string()
12274 .contains("Stored execution payload requires unavailable key"),
12275 "was: {error}"
12276 );
12277 assert!(client.signer.is_none());
12278 assert!(client.payload_keys.is_none());
12279 assert!(state.recorded_requests().is_empty());
12280
12281 drop(client);
12282 drop_execution_schema(&admin_pool, &schema).await;
12283 }
12284
12285 #[tokio::test]
12286 async fn payload_action_lease_blocks_rewrap_transition_until_release() {
12287 let Some((admin_pool, schema, client, _)) = execution_client_with_unprotected_database(
12288 "payload_action_lease",
12289 execution_rpc_state(),
12290 )
12291 .await
12292 else {
12293 return;
12294 };
12295 let database = client.cache.database.as_ref().unwrap();
12296 let keys = payload_test_keys([0x71; 32], vec![], "action-lease");
12297 database
12298 .ensure_execution_payload_storage(&keys)
12299 .await
12300 .unwrap();
12301 let lease = database
12302 .acquire_execution_payload_lease(&keys)
12303 .await
12304 .unwrap();
12305 let rotated = payload_test_keys([0x72; 32], vec![[0x71; 32]], "action-lease");
12306 let mut operation = Box::pin(database.rewrap_execution_payload_storage(&rotated, 1));
12307
12308 assert!(
12309 tokio::time::timeout(Duration::from_millis(50), &mut operation)
12310 .await
12311 .is_err()
12312 );
12313 let state: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
12314 "SELECT operation FROM {schema}.execution_payload_state"
12315 )))
12316 .fetch_one(&admin_pool)
12317 .await
12318 .unwrap();
12319 assert_eq!(state, "ready");
12320
12321 drop(lease);
12322 tokio::time::timeout(Duration::from_secs(2), operation)
12323 .await
12324 .unwrap()
12325 .unwrap();
12326 let state: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
12327 "SELECT operation FROM {schema}.execution_payload_state"
12328 )))
12329 .fetch_one(&admin_pool)
12330 .await
12331 .unwrap();
12332 assert_eq!(state, "ready");
12333
12334 drop_execution_schema(&admin_pool, &schema).await;
12335 }
12336
12337 #[tokio::test]
12338 async fn payload_infrastructure_checks_are_scoped_to_the_execution_schema() {
12339 let Some((admin_pool, pg_config)) = connect_test_postgres("payload schema isolation").await
12340 else {
12341 return;
12342 };
12343 let schema_a = format!("payload_schema_a_{}", std::process::id());
12344 let schema_b = format!("payload_schema_b_{}", std::process::id());
12345 setup_execution_schema(&admin_pool, &schema_a).await;
12346 setup_execution_schema(&admin_pool, &schema_b).await;
12347 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
12348 let database_a =
12349 connect_test_database(options.clone().options([("search_path", schema_a.clone())]))
12350 .await
12351 .unwrap();
12352 let database_b =
12353 connect_test_database(options.options([("search_path", schema_b.clone())]))
12354 .await
12355 .unwrap();
12356 database_a
12357 .ensure_execution_transaction_schema()
12358 .await
12359 .unwrap();
12360 database_b
12361 .ensure_execution_transaction_schema()
12362 .await
12363 .unwrap();
12364 let keys_a = payload_test_keys([0x91; 32], vec![], "schema-a");
12365 let keys_b = payload_test_keys([0x92; 32], vec![], "schema-b");
12366
12367 database_a
12368 .ensure_execution_payload_storage(&keys_a)
12369 .await
12370 .unwrap();
12371 database_b
12372 .ensure_execution_payload_storage(&keys_b)
12373 .await
12374 .unwrap();
12375
12376 for statement in [
12377 format!(
12378 "DROP TRIGGER execution_transaction_payload_fence ON \
12379 {schema_a}.execution_transaction_hash"
12380 ),
12381 format!(
12382 "ALTER TABLE {schema_a}.execution_transaction_hash \
12383 DROP CONSTRAINT execution_transaction_payload_protected_check"
12384 ),
12385 ] {
12386 sqlx::query(sqlx::AssertSqlSafe(statement))
12387 .execute(&admin_pool)
12388 .await
12389 .unwrap();
12390 }
12391
12392 let error = database_a
12393 .ensure_execution_payload_storage(&keys_a)
12394 .await
12395 .unwrap_err();
12396
12397 assert!(
12398 error
12399 .to_string()
12400 .contains("without its write fence or constraint"),
12401 "was: {error}"
12402 );
12403
12404 drop_execution_schema(&admin_pool, &schema_a).await;
12405 drop_execution_schema(&admin_pool, &schema_b).await;
12406 }
12407
12408 #[tokio::test]
12409 async fn execution_fresh_schema_preserves_nullable_wallet_address() {
12410 let Some((admin_pool, _)) = connect_test_postgres("fresh execution schema").await else {
12411 return;
12412 };
12413 let schema = format!("execution_fresh_test_{}", std::process::id());
12414 let mut transaction = admin_pool.begin().await.unwrap();
12415 sqlx::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {schema}")))
12416 .execute(&mut *transaction)
12417 .await
12418 .unwrap();
12419 sqlx::query(sqlx::AssertSqlSafe(format!(
12420 "SET LOCAL search_path TO {schema}"
12421 )))
12422 .execute(&mut *transaction)
12423 .await
12424 .unwrap();
12425 sqlx::query("CREATE TABLE chain (chain_id INTEGER PRIMARY KEY)")
12426 .execute(&mut *transaction)
12427 .await
12428 .unwrap();
12429 sqlx::query("INSERT INTO chain (chain_id) VALUES (42161)")
12430 .execute(&mut *transaction)
12431 .await
12432 .unwrap();
12433 sqlx::query(sqlx::AssertSqlSafe(execution_transaction_create_sql()))
12434 .execute(&mut *transaction)
12435 .await
12436 .unwrap();
12437 sqlx::query(
12438 "INSERT INTO execution_transaction \
12439 (chain_id, nonce, transaction_hash, purpose, status) \
12440 VALUES (42161, 7, '0xfresh-legacy', 'wrap', 'rejected')",
12441 )
12442 .execute(&mut *transaction)
12443 .await
12444 .unwrap();
12445
12446 let is_nullable: String = sqlx::query_scalar(
12447 "SELECT is_nullable FROM information_schema.columns \
12448 WHERE table_schema = $1 AND table_name = 'execution_transaction' \
12449 AND column_name = 'wallet_address'",
12450 )
12451 .bind(&schema)
12452 .fetch_one(&mut *transaction)
12453 .await
12454 .unwrap();
12455 let wallet_address: Option<String> = sqlx::query_scalar(
12456 "SELECT wallet_address FROM execution_transaction \
12457 WHERE transaction_hash = '0xfresh-legacy'",
12458 )
12459 .fetch_one(&mut *transaction)
12460 .await
12461 .unwrap();
12462
12463 assert_eq!(is_nullable, "YES");
12464 assert_eq!(wallet_address, None);
12465 transaction.rollback().await.unwrap();
12466 }
12467
12468 #[tokio::test]
12469 async fn execution_schema_migration_preserves_existing_rows() {
12470 let Some((admin_pool, pg_config)) =
12471 connect_test_postgres("execution schema migration").await
12472 else {
12473 return;
12474 };
12475 let schema = format!("execution_migration_test_{}", std::process::id());
12476 setup_execution_schema(&admin_pool, &schema).await;
12477 sqlx::query(sqlx::AssertSqlSafe(format!(
12478 "INSERT INTO {schema}.execution_transaction \
12479 (chain_id, nonce, transaction_hash, purpose, status) \
12480 VALUES (42161, 7, '0xlegacy', 'wrap', 'rejected')"
12481 )))
12482 .execute(&admin_pool)
12483 .await
12484 .unwrap();
12485
12486 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
12487 let db_options = db_options.options([("search_path", schema.clone())]);
12488 let database = connect_test_database(db_options).await.unwrap();
12489 database
12490 .ensure_execution_transaction_schema()
12491 .await
12492 .unwrap();
12493 let legacy = database
12494 .get_execution_transaction(42161, "0xlegacy")
12495 .await
12496 .unwrap()
12497 .unwrap();
12498 assert_eq!(legacy.purpose, "wrap");
12499 assert_eq!(legacy.status, "rejected");
12500 assert_eq!(legacy.client_order_id, None);
12501 assert_eq!(legacy.wallet_address, None);
12502 let is_nullable: String = sqlx::query_scalar(
12503 "SELECT is_nullable FROM information_schema.columns \
12504 WHERE table_schema = $1 AND table_name = 'execution_transaction' \
12505 AND column_name = 'wallet_address'",
12506 )
12507 .bind(&schema)
12508 .fetch_one(&admin_pool)
12509 .await
12510 .unwrap();
12511 let fence_error = database
12512 .add_execution_transaction(
12513 42161,
12514 WALLET,
12515 8,
12516 "0xswap",
12517 "swap",
12518 "pending",
12519 Some("O-SWAP-001"),
12520 )
12521 .await
12522 .err()
12523 .unwrap();
12524 assert!(
12525 fence_error
12526 .to_string()
12527 .contains("Legacy execution writer refused"),
12528 "was: {fence_error}"
12529 );
12530 assert_eq!(is_nullable, "YES");
12531
12532 drop_execution_schema(&admin_pool, &schema).await;
12533 }
12534
12535 #[tokio::test]
12536 async fn execution_schema_migration_drops_legacy_wallet_not_null() {
12537 let Some((admin_pool, pg_config)) =
12538 connect_test_postgres("legacy wallet address schema migration").await
12539 else {
12540 return;
12541 };
12542 let schema = format!("execution_wallet_migration_test_{}", std::process::id());
12543 setup_execution_schema(&admin_pool, &schema).await;
12544 sqlx::query(sqlx::AssertSqlSafe(format!(
12545 "ALTER TABLE {schema}.execution_transaction \
12546 ADD COLUMN wallet_address TEXT NOT NULL"
12547 )))
12548 .execute(&admin_pool)
12549 .await
12550 .unwrap();
12551 sqlx::query(sqlx::AssertSqlSafe(format!(
12552 "INSERT INTO {schema}.execution_transaction \
12553 (chain_id, wallet_address, nonce, transaction_hash, purpose, status) \
12554 VALUES (42161, '{WALLET}', 7, '0xlegacy-wallet', 'wrap', 'rejected')"
12555 )))
12556 .execute(&admin_pool)
12557 .await
12558 .unwrap();
12559
12560 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
12561 let db_options = db_options.options([("search_path", schema.clone())]);
12562 let database = connect_test_database(db_options).await.unwrap();
12563 database
12564 .ensure_execution_transaction_schema()
12565 .await
12566 .unwrap();
12567 let legacy = database
12568 .get_execution_transaction(42161, "0xlegacy-wallet")
12569 .await
12570 .unwrap()
12571 .unwrap();
12572 let is_nullable: String = sqlx::query_scalar(
12573 "SELECT is_nullable FROM information_schema.columns \
12574 WHERE table_schema = $1 AND table_name = 'execution_transaction' \
12575 AND column_name = 'wallet_address'",
12576 )
12577 .bind(&schema)
12578 .fetch_one(&admin_pool)
12579 .await
12580 .unwrap();
12581
12582 assert_eq!(legacy.wallet_address.as_deref(), Some(WALLET));
12583 assert_eq!(is_nullable, "YES");
12584 drop_execution_schema(&admin_pool, &schema).await;
12585 }
12586
12587 #[tokio::test]
12588 async fn execution_schema_migration_refuses_unresolved_legacy_rows() {
12589 let Some((admin_pool, pg_config)) =
12590 connect_test_postgres("unsafe execution schema migration").await
12591 else {
12592 return;
12593 };
12594 let schema = format!("execution_unsafe_migration_test_{}", std::process::id());
12595 setup_execution_schema(&admin_pool, &schema).await;
12596 sqlx::query(sqlx::AssertSqlSafe(format!(
12597 "INSERT INTO {schema}.execution_transaction \
12598 (chain_id, nonce, transaction_hash, purpose, status) \
12599 VALUES (42161, 7, '0xunresolved', 'wrap', 'pending')"
12600 )))
12601 .execute(&admin_pool)
12602 .await
12603 .unwrap();
12604 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
12605 let db_options = db_options.options([("search_path", schema.clone())]);
12606 let database = connect_test_database(db_options).await.unwrap();
12607
12608 let error = database
12609 .ensure_execution_transaction_schema()
12610 .await
12611 .unwrap_err();
12612 let legacy_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
12613 "SELECT COUNT(*) FROM {schema}.execution_transaction \
12614 WHERE transaction_hash = '0xunresolved' AND status = 'pending'"
12615 )))
12616 .fetch_one(&admin_pool)
12617 .await
12618 .unwrap();
12619 let v2_table: Option<String> = sqlx::query_scalar("SELECT to_regclass($1)::TEXT")
12620 .bind(format!("{schema}.execution_intent"))
12621 .fetch_one(&admin_pool)
12622 .await
12623 .unwrap();
12624
12625 assert!(
12626 error
12627 .to_string()
12628 .contains("Cannot safely migrate 1 unresolved execution schema version 1"),
12629 "was: {error}"
12630 );
12631 assert_eq!(legacy_count, 1);
12632 assert_eq!(v2_table, None);
12633
12634 drop_execution_schema(&admin_pool, &schema).await;
12635 }
12636
12637 #[rstest]
12638 fn receipt_max_polls_derives_from_timeout() {
12639 assert_eq!(receipt_max_polls(0), 1);
12640 assert_eq!(receipt_max_polls(1), 1);
12641 assert_eq!(receipt_max_polls(60), 60);
12642 assert_eq!(receipt_max_polls(u64::MAX), u32::MAX);
12643 assert_eq!(receipt_timeout(0), Duration::from_secs(1));
12644 assert_eq!(receipt_timeout(60), Duration::from_secs(60));
12645 assert_eq!(
12646 receipt_timeout(u64::MAX),
12647 Duration::from_secs(u64::from(u32::MAX))
12648 );
12649 }
12650
12651 #[rstest]
12652 fn submit_order_errors_when_order_not_cached() {
12653 let client = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string())).0;
12654 let mut cmd = submit_order_cmd(&test_market_sell_order(test_pool().instrument_id));
12655 cmd.client_order_id = ClientOrderId::from("O-UNKNOWN");
12656
12657 let error = client.submit_order(cmd).unwrap_err();
12658
12659 assert!(!error.to_string().is_empty());
12660 }
12661
12662 #[tokio::test]
12663 async fn submit_order_denies_pool_fee_above_uint24() {
12664 let cache = Rc::new(RefCell::new(Cache::default()));
12665 let mut pool = test_pool();
12666 pool.fee = Some(16_777_216); cache.borrow_mut().add_pool(pool.clone()).unwrap();
12668 let order = test_market_sell_order(pool.instrument_id);
12669 cache
12670 .borrow_mut()
12671 .add_order(order.clone(), None, None, false)
12672 .unwrap();
12673 let core = ExecutionClientCore::new(
12674 TraderId::from("TRADER-001"),
12675 ClientId::from("BLOCKCHAIN-001"),
12676 *BLOCKCHAIN_VENUE,
12677 OmsType::Netting,
12678 AccountId::from("BLOCKCHAIN-001"),
12679 AccountType::Wallet,
12680 None,
12681 cache,
12682 );
12683 let mut client =
12684 BlockchainExecutionClient::new(core, test_config("http://127.0.0.1:1".to_string()))
12685 .unwrap();
12686 let mut receiver = start_with_events(&mut client);
12687
12688 client.submit_order(submit_order_cmd(&order)).unwrap();
12689
12690 let events = collect_order_events(&mut receiver);
12691 assert_eq!(events.len(), 1);
12692 let OrderEventAny::Denied(denied) = &events[0] else {
12693 panic!("expected OrderDenied, was {:?}", events[0]);
12694 };
12695 assert!(
12696 denied.reason.contains("exceeds uint24"),
12697 "was: {}",
12698 denied.reason
12699 );
12700 }
12701
12702 #[tokio::test]
12703 async fn submit_order_denies_uninitialized_profiler() {
12704 let cache = Rc::new(RefCell::new(Cache::default()));
12705 let pool = test_pool();
12706 cache.borrow_mut().add_pool(pool.clone()).unwrap();
12707 let order = test_market_sell_order(pool.instrument_id);
12708 cache
12709 .borrow_mut()
12710 .add_order(order.clone(), None, None, false)
12711 .unwrap();
12712 cache
12713 .borrow_mut()
12714 .add_pool_profiler(PoolProfiler::new(Arc::new(pool)))
12715 .unwrap();
12716 let core = ExecutionClientCore::new(
12717 TraderId::from("TRADER-001"),
12718 ClientId::from("BLOCKCHAIN-001"),
12719 *BLOCKCHAIN_VENUE,
12720 OmsType::Netting,
12721 AccountId::from("BLOCKCHAIN-001"),
12722 AccountType::Wallet,
12723 None,
12724 cache,
12725 );
12726 let mut client =
12727 BlockchainExecutionClient::new(core, test_config("http://127.0.0.1:1".to_string()))
12728 .unwrap();
12729 let mut receiver = start_with_events(&mut client);
12730
12731 client.submit_order(submit_order_cmd(&order)).unwrap();
12732
12733 let events = collect_order_events(&mut receiver);
12734 assert_eq!(events.len(), 1);
12735 let OrderEventAny::Denied(denied) = &events[0] else {
12736 panic!("expected OrderDenied, was {:?}", events[0]);
12737 };
12738 assert!(
12739 denied.reason.contains("is not initialized"),
12740 "was: {}",
12741 denied.reason
12742 );
12743 }
12744
12745 #[tokio::test]
12746 async fn submit_order_denies_when_quote_cannot_fill_order() {
12747 let cache = Rc::new(RefCell::new(Cache::default()));
12748 let pool = test_pool();
12749 cache.borrow_mut().add_pool(pool.clone()).unwrap();
12750 let order = test_market_sell_order(pool.instrument_id);
12751 cache
12752 .borrow_mut()
12753 .add_order(order.clone(), None, None, false)
12754 .unwrap();
12755 cache
12757 .borrow_mut()
12758 .add_pool_profiler(test_profiler_with_range(
12759 &pool,
12760 FIXTURE_BLOCK,
12761 FIXTURE_BLOCK_HASH,
12762 U160::from(1u128 << 96),
12763 -10,
12764 10,
12765 1,
12766 ))
12767 .unwrap();
12768 let core = ExecutionClientCore::new(
12769 TraderId::from("TRADER-001"),
12770 ClientId::from("BLOCKCHAIN-001"),
12771 *BLOCKCHAIN_VENUE,
12772 OmsType::Netting,
12773 AccountId::from("BLOCKCHAIN-001"),
12774 AccountType::Wallet,
12775 None,
12776 cache,
12777 );
12778 let mut client =
12779 BlockchainExecutionClient::new(core, test_config("http://127.0.0.1:1".to_string()))
12780 .unwrap();
12781 let mut receiver = start_with_events(&mut client);
12782
12783 client.submit_order(submit_order_cmd(&order)).unwrap();
12784
12785 let events = collect_order_events(&mut receiver);
12786 assert_eq!(events.len(), 1);
12787 let OrderEventAny::Denied(denied) = &events[0] else {
12788 panic!("expected OrderDenied, was {:?}", events[0]);
12789 };
12790 assert!(
12791 denied.reason.contains("cannot fill the order"),
12792 "was: {}",
12793 denied.reason
12794 );
12795 }
12796
12797 #[tokio::test]
12798 async fn submit_order_applies_slippage_param_override_at_ceiling() {
12799 let Some((admin_pool, schema, mut client, state, _)) = swap_client_with_database(
12800 "execution_submit_slippage_override_test",
12801 swap_rpc_state_with_min_amount_out(expected_min_amount_out(200)).await,
12802 )
12803 .await
12804 else {
12805 return;
12806 };
12807 let order = test_market_sell_order(test_pool().instrument_id);
12808 let mut cmd = submit_order_cmd(&order);
12809 cmd.params = Some(serde_json::from_str(r#"{"slippage_bps": 200}"#).unwrap());
12811 let mut receiver = start_with_events(&mut client);
12812 let expected_min_out = expected_min_amount_out(200);
12813
12814 client.submit_order(cmd).unwrap();
12815 await_pending_tasks(&client).await;
12816
12817 let events = collect_order_events(&mut receiver);
12818 assert_swap_submitted_and_filled(&events);
12819
12820 let (_, expected_raw) = expected_swap_tx(expected_min_out).await;
12821 let broadcasts: Vec<_> = state
12822 .recorded_requests()
12823 .into_iter()
12824 .filter(|request| request["method"] == "eth_sendRawTransaction")
12825 .collect();
12826 assert_eq!(broadcasts.len(), 1);
12827 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
12828
12829 drop_execution_schema(&admin_pool, &schema).await;
12830 }
12831
12832 #[tokio::test]
12833 async fn submit_order_accepts_quote_fresh_at_max_age_boundary() {
12834 let min_amount_out = expected_min_amount_out(50);
12835 let (tx_hash, _) = expected_swap_tx(min_amount_out).await;
12836 let head = finalized_swap_block(tx_hash, min_amount_out);
12837 let state = swap_rpc_state()
12838 .await
12839 .with_response("eth_getBlockByNumber", &head)
12840 .with_parameter_response("eth_getBlockByNumber", FIXTURE_BLOCK_PARAM, BLOCK_BY_NUMBER);
12841 let Some((admin_pool, schema, mut client, state, cache)) =
12842 swap_client_with_database_config(
12843 "execution_submit_fresh_boundary_test",
12844 state,
12845 |http_rpc_url| {
12846 let mut config = test_config(http_rpc_url);
12847 config.max_quote_age_blocks = Some(1);
12848 config
12849 },
12850 )
12851 .await
12852 else {
12853 return;
12854 };
12855 let pool = test_pool();
12856 cache
12857 .borrow_mut()
12858 .add_pool_profiler(test_profiler_at_block(
12859 &pool,
12860 FIXTURE_BLOCK,
12861 FIXTURE_BLOCK_HASH,
12862 ))
12863 .unwrap();
12864 let order = test_market_sell_order(pool.instrument_id);
12865 let mut receiver = start_with_events(&mut client);
12866
12867 client.submit_order(submit_order_cmd(&order)).unwrap();
12868 await_pending_tasks(&client).await;
12869
12870 let events = collect_order_events(&mut receiver);
12871 assert_eq!(events.len(), 1, "was: {events:?}");
12872 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
12873 assert_eq!(
12874 state
12875 .recorded_requests()
12876 .iter()
12877 .filter(|request| request["method"] == "eth_sendRawTransaction")
12878 .count(),
12879 1
12880 );
12881
12882 drop_execution_schema(&admin_pool, &schema).await;
12883 }
12884
12885 #[tokio::test]
12886 async fn submit_order_sells_base_token_from_token1_position() {
12887 let chain = Arc::new(chains::ARBITRUM.clone());
12891 let dex = Arc::clone(&UNISWAP_V3.dex);
12892 let usdc = Token::new(
12893 Arc::clone(&chain),
12894 address!("af88d065e77c8cC2239327C5EDb3A432268e5831"),
12895 "USD Coin".to_string(),
12896 "USDC".to_string(),
12897 6,
12898 );
12899 let weth = Token::new(
12900 Arc::clone(&chain),
12901 address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
12902 "Wrapped Ether".to_string(),
12903 "WETH".to_string(),
12904 18,
12905 );
12906 let pool = Pool::new(
12907 chain,
12908 dex,
12909 address!("C6962004f452bE9203591991D15f6b388e09E8D0"),
12910 PoolIdentifier::from_address(address!("C6962004f452bE9203591991D15f6b388e09E8D0")),
12911 55_000_000,
12912 usdc,
12913 weth,
12914 Some(500),
12915 Some(10),
12916 UnixNanos::default(),
12917 );
12918
12919 let cache = Rc::new(RefCell::new(Cache::default()));
12920 cache.borrow_mut().add_pool(pool.clone()).unwrap();
12921 let order = test_market_sell_order(pool.instrument_id);
12922 cache
12923 .borrow_mut()
12924 .add_order(order.clone(), None, None, false)
12925 .unwrap();
12926 cache
12928 .borrow_mut()
12929 .add_pool_profiler(test_profiler_with_state(
12930 &pool,
12931 FIXTURE_BLOCK,
12932 U160::from(2u128 << 96),
12933 TEST_LIQUIDITY,
12934 ))
12935 .unwrap();
12936
12937 let (admin_pool, pg_config) = match connect_test_postgres("orientation").await {
12938 Some(setup) => setup,
12939 None => return,
12940 };
12941 let schema = format!("execution_submit_orientation_test_{}", std::process::id());
12942 setup_execution_schema(&admin_pool, &schema).await;
12943 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
12944 let db_options = db_options.options([("search_path", schema.clone())]);
12945 let database = connect_test_database(db_options).await.unwrap();
12946
12947 let state = swap_rpc_state()
12948 .await
12949 .with_call_response(POOL_TOKEN0_SELECTOR, CALL_USDC)
12950 .with_call_response(POOL_TOKEN1_SELECTOR, CALL_WETH)
12951 .with_response("eth_getTransactionReceipt", RECEIPT_NULL);
12952 let addr = start_mock_rpc_server(state.clone()).await;
12953 let core = ExecutionClientCore::new(
12954 TraderId::from("TRADER-001"),
12955 ClientId::from("BLOCKCHAIN-001"),
12956 *BLOCKCHAIN_VENUE,
12957 OmsType::Netting,
12958 AccountId::from("BLOCKCHAIN-001"),
12959 AccountType::Wallet,
12960 None,
12961 cache,
12962 );
12963 let mut config = test_config(format!("http://{addr}"));
12964 let result = |response: &str| {
12965 serde_json::from_str::<serde_json::Value>(response).unwrap()["result"]
12966 .as_str()
12967 .unwrap()
12968 .to_string()
12969 };
12970 {
12971 let verification = config.verification.as_mut().unwrap();
12972 let pool_identity = &mut verification.deployment_manifest.pools[0];
12973 pool_identity.token0 = USDC.to_string();
12974 pool_identity.token1 = WETH.to_string();
12975 let pool_contract = verification
12976 .deployment_manifest
12977 .contracts
12978 .iter_mut()
12979 .find(|contract| contract.role == BlockchainContractRole::Pool)
12980 .unwrap();
12981
12982 for probe in &mut pool_contract.probes {
12983 if probe.call_data.starts_with(POOL_TOKEN0_SELECTOR) {
12984 probe.expected_output = result(CALL_USDC);
12985 } else if probe.call_data.starts_with(POOL_TOKEN1_SELECTOR) {
12986 probe.expected_output = result(CALL_WETH);
12987 }
12988 }
12989 }
12990 refresh_test_manifest_digest(&mut config);
12991 let mut client = BlockchainExecutionClient::new(core, config).unwrap();
12992 client.cache.database = Some(database);
12993 client
12994 .cache
12995 .ensure_execution_transaction_schema()
12996 .await
12997 .unwrap();
12998 protect_test_storage(&mut client, &schema).await;
12999 initialize_test_verification_ledger(&client).await;
13000 client.signer = Some(Arc::new(
13001 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
13002 ));
13003 client.core.set_connected();
13004 let mut receiver = start_with_events(&mut client);
13005
13006 let profiler = test_profiler_with_state(
13007 &pool,
13008 FIXTURE_BLOCK,
13009 U160::from(2u128 << 96),
13010 TEST_LIQUIDITY,
13011 );
13012 let quote = profiler
13013 .swap_exact_in(U256::from(1_000_000_000_000_000u64), false, None)
13014 .unwrap();
13015 let quoted = exact_output_amount("e, false).unwrap();
13016 let expected_min_out = derive_min_amount_out(quoted, 50).unwrap();
13017 let wrong_direction_quote = profiler
13018 .swap_exact_in(U256::from(1_000_000_000_000_000u64), true, None)
13019 .unwrap();
13020 let wrong_direction_out = exact_output_amount(&wrong_direction_quote, true).unwrap();
13021 assert_ne!(
13022 quoted, wrong_direction_out,
13023 "the asymmetric price must make the quote direction observable"
13024 );
13025 assert_ne!(
13026 expected_min_out,
13027 expected_min_amount_out(50),
13028 "the profiler quote must remain distinct from the independent quote fixture"
13029 );
13030
13031 client.submit_order(submit_order_cmd(&order)).unwrap();
13032 await_pending_tasks(&client).await;
13033
13034 let events = collect_order_events(&mut receiver);
13035 assert_eq!(events.len(), 1);
13036 assert!(
13037 matches!(&events[0], OrderEventAny::Submitted(_)),
13038 "was: {:?}",
13039 events[0]
13040 );
13041
13042 let (_, expected_raw) = expected_swap_tx(expected_min_amount_out(50)).await;
13043 let broadcasts: Vec<_> = state
13044 .recorded_requests()
13045 .into_iter()
13046 .filter(|request| request["method"] == "eth_sendRawTransaction")
13047 .collect();
13048 assert_eq!(broadcasts.len(), 1);
13049 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
13050
13051 drop_execution_schema(&admin_pool, &schema).await;
13052 }
13053
13054 #[tokio::test]
13055 async fn submit_order_denies_on_chain_mismatch() {
13056 let state = swap_rpc_state()
13057 .await
13058 .with_response("eth_chainId", CHAIN_ID_ETHEREUM);
13059 let Some((admin_pool, schema, mut client, state, _)) =
13060 swap_client_with_database("execution_submit_chain_mismatch_test", state).await
13061 else {
13062 return;
13063 };
13064 let order = test_market_sell_order(test_pool().instrument_id);
13065 let mut receiver = start_with_events(&mut client);
13066
13067 client.submit_order(submit_order_cmd(&order)).unwrap();
13068 await_pending_tasks(&client).await;
13069
13070 let events = collect_order_events(&mut receiver);
13071 assert_eq!(events.len(), 1);
13072 let OrderEventAny::Denied(denied) = &events[0] else {
13073 panic!("expected OrderDenied, was {:?}", events[0]);
13074 };
13075 assert!(
13076 denied
13077 .reason
13078 .contains("pre-sign chain ID verification disagreed"),
13079 "was: {}",
13080 denied.reason
13081 );
13082 let requests = state.recorded_requests();
13083 assert!(
13084 requests
13085 .iter()
13086 .all(|request| request["method"] != "eth_sendRawTransaction"),
13087 "no broadcast may follow a chain mismatch"
13088 );
13089 assert!(client.in_flight.lock().is_none());
13090
13091 drop_execution_schema(&admin_pool, &schema).await;
13092 }
13093
13094 #[tokio::test]
13095 async fn submit_order_finality_timeout_marks_dropped_and_keeps_ownership() {
13096 let receipt_release = Arc::new(tokio::sync::Semaphore::new(0));
13097 let state = swap_rpc_state()
13098 .await
13099 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
13100 .with_response_release("eth_getTransactionReceipt", Arc::clone(&receipt_release));
13101 let Some((admin_pool, schema, mut client, state, _)) =
13102 swap_client_with_database("execution_submit_inclusion_timeout_test", state).await
13103 else {
13104 return;
13105 };
13106 client.transaction_limits.receipt_timeout_secs = 1;
13107 let order = test_market_sell_order(test_pool().instrument_id);
13108 let mut receiver = start_with_events(&mut client);
13109
13110 client.submit_order(submit_order_cmd(&order)).unwrap();
13111 await_recorded_requests(&state, "eth_getTransactionReceipt", 3).await;
13112 tokio::time::timeout(Duration::from_secs(3), await_pending_tasks(&client))
13113 .await
13114 .unwrap();
13115 receipt_release.add_permits(3);
13116
13117 let events = collect_order_events(&mut receiver);
13120 assert_eq!(events.len(), 1);
13121 assert!(
13122 matches!(&events[0], OrderEventAny::Submitted(_)),
13123 "was: {:?}",
13124 events[0]
13125 );
13126
13127 let record = client
13128 .cache
13129 .get_execution_transaction(
13130 42161,
13131 &expected_swap_tx(expected_min_amount_out(50))
13132 .await
13133 .0
13134 .to_string(),
13135 )
13136 .await
13137 .unwrap()
13138 .unwrap();
13139 assert_eq!(record.status, "dropped");
13140 assert!(client.in_flight.lock().is_some());
13141
13142 drop_execution_schema(&admin_pool, &schema).await;
13143 }
13144
13145 #[tokio::test]
13146 async fn included_receipt_finality_timeout_marks_dropped_and_keeps_ownership() {
13147 let state = execution_rpc_state()
13148 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
13149 .with_response("eth_estimateGas", ESTIMATE_GAS)
13150 .with_response("eth_call", CALL_BALANCE)
13151 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS)
13152 .with_parameter_response("eth_getBlockByNumber", "finalized", BLOCK_BY_NUMBER)
13153 .with_send_raw_transaction_echo();
13154 let Some((admin_pool, schema, mut client, _)) =
13155 execution_client_with_database("execution_included_timeout_test", state).await
13156 else {
13157 return;
13158 };
13159 client.transaction_limits.receipt_timeout_secs = 1;
13160
13161 let error = client
13162 .wrap(U256::from(1_000_000_000_000_000_u64))
13163 .await
13164 .unwrap_err();
13165 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
13166 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
13167 )))
13168 .fetch_all(&admin_pool)
13169 .await
13170 .unwrap();
13171
13172 assert!(
13173 error.to_string().contains("Timed out awaiting finality"),
13174 "was: {error}"
13175 );
13176 assert_eq!(transitions, ["prepared", "signed", "broadcast", "dropped"]);
13177 assert!(client.in_flight.lock().is_some());
13178
13179 drop_execution_schema(&admin_pool, &schema).await;
13180 }
13181
13182 #[tokio::test]
13183 async fn submit_order_single_in_flight_rejects_concurrent_swap() {
13184 let broadcast_release = Arc::new(tokio::sync::Semaphore::new(0));
13185 let state = swap_rpc_state()
13186 .await
13187 .with_response_release("eth_sendRawTransaction", Arc::clone(&broadcast_release));
13188 let Some((admin_pool, schema, mut client, state, cache)) =
13189 swap_client_with_database("execution_submit_concurrent_test", state).await
13190 else {
13191 return;
13192 };
13193 let pool = test_pool();
13194 let first = test_market_sell_order(pool.instrument_id);
13195 let second = OrderTestBuilder::new(OrderType::Market)
13196 .trader_id(TraderId::from("TRADER-001"))
13197 .strategy_id(StrategyId::from("S-001"))
13198 .instrument_id(pool.instrument_id)
13199 .client_order_id(ClientOrderId::from("O-SWAP-002"))
13200 .side(OrderSide::Sell)
13201 .quantity(Quantity::from("0.001"))
13202 .build();
13203 cache
13204 .borrow_mut()
13205 .add_order(second.clone(), None, None, false)
13206 .unwrap();
13207 let mut receiver = start_with_events(&mut client);
13208
13209 client.submit_order(submit_order_cmd(&first)).unwrap();
13210 await_recorded_requests(&state, "eth_sendRawTransaction", 1).await;
13211 client.submit_order(submit_order_cmd(&second)).unwrap();
13212 let event = tokio::time::timeout(TEST_TIMEOUT, receiver.recv())
13213 .await
13214 .unwrap()
13215 .unwrap();
13216 let ExecutionEvent::Order(OrderEventAny::Denied(denied)) = event else {
13217 panic!("expected OrderDenied, was {event:?}");
13218 };
13219 assert_eq!(denied.client_order_id, second.client_order_id());
13220 assert!(
13221 denied
13222 .reason
13223 .contains("at most one transaction can be in flight"),
13224 "was: {}",
13225 denied.reason
13226 );
13227 broadcast_release.add_permits(1);
13228 await_pending_tasks(&client).await;
13229
13230 let events = collect_order_events(&mut receiver);
13231 assert_swap_submitted_and_filled(&events);
13232
13233 let broadcasts = state
13234 .recorded_requests()
13235 .into_iter()
13236 .filter(|request| request["method"] == "eth_sendRawTransaction")
13237 .count();
13238 assert_eq!(broadcasts, 1);
13239 let row_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
13240 "SELECT COUNT(*) FROM {schema}.execution_intent"
13241 )))
13242 .fetch_one(&admin_pool)
13243 .await
13244 .unwrap();
13245 assert_eq!(row_count, 1);
13246 assert!(client.in_flight.lock().is_none());
13247
13248 drop_execution_schema(&admin_pool, &schema).await;
13249 }
13250
13251 #[tokio::test]
13252 async fn preflight_ready_when_all_checks_pass() {
13253 let (client, state) = client_with_mock_rpc(ready_rpc_state()).await;
13254 let pool = test_pool();
13255
13256 let report = client.preflight(&pool.instrument_id).await.unwrap();
13257
13258 assert!(report.ready, "issues: {:?}", report.issues);
13259 assert!(report.issues.is_empty());
13260 assert_eq!(report.expected_chain_id, 42161);
13261 assert_eq!(report.actual_chain_id, 42161);
13262 assert!(report.chain_id_matches);
13263 assert_eq!(
13264 report.pool.address,
13265 address!("C6962004f452bE9203591991D15f6b388e09E8D0")
13266 );
13267 assert!(report.pool.has_deployed_code);
13268 assert_eq!(report.pool.fee, Some(500));
13269 assert_eq!(
13270 report.pool.base_token,
13271 address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1")
13272 );
13273 assert_eq!(
13274 report.pool.quote_token,
13275 address!("af88d065e77c8cC2239327C5EDb3A432268e5831")
13276 );
13277 assert_eq!(report.routers.len(), 1);
13278 assert!(report.routers[0].has_deployed_code);
13279 assert_eq!(report.tokens.len(), 2);
13280 assert_eq!(
13281 report.tokens[0].wallet_balance,
13282 U256::from(500_000_000_000_000_000u64)
13283 );
13284 assert_eq!(
13285 report.tokens[0].router_allowances,
13286 vec![(
13287 address!("E592427A0AEce92De3Edee1F18E0157C05861564"),
13288 U256::from(1_000_000_000_000_000_000u64)
13289 )]
13290 );
13291 assert_eq!(
13292 report.native_balance_wei,
13293 U256::from(1_000_000_000_000_000_000u64)
13294 );
13295 assert_eq!(report.base_fee_per_gas_wei, 100_000_000);
13296 assert_eq!(report.max_priority_fee_per_gas_wei, 10_000_000);
13297 assert_eq!(report.derived_max_fee_per_gas_wei, 130_000_000);
13298 assert!(report.fee_within_ceiling);
13299
13300 let requests = state.recorded_requests();
13301 for method in ["eth_getCode", "eth_call", "eth_getBalance"] {
13302 let matching: Vec<_> = requests
13303 .iter()
13304 .filter(|request| request["method"] == method)
13305 .collect();
13306 assert!(!matching.is_empty(), "method {method}");
13307 assert!(
13308 matching
13309 .iter()
13310 .all(|request| request["params"][1] == "latest"),
13311 "method {method}: {matching:?}"
13312 );
13313 }
13314 }
13315
13316 #[tokio::test]
13317 async fn preflight_not_ready_without_pool_fee() {
13318 let addr = start_mock_rpc_server(ready_rpc_state()).await;
13319 let mut pool = test_pool();
13320 pool.fee = None;
13321 let client = test_client_from_config(test_config(format!("http://{addr}")), pool.clone());
13322
13323 let report = client.preflight(&pool.instrument_id).await.unwrap();
13324
13325 assert!(!report.ready);
13326 assert_eq!(report.pool.fee, None);
13327 assert_eq!(report.issues, vec!["Pool fee tier is missing"]);
13328 }
13329
13330 #[tokio::test]
13331 async fn preflight_not_ready_on_wrong_chain() {
13332 let state = ready_rpc_state().with_response("eth_chainId", CHAIN_ID_ETHEREUM);
13333 let (client, _) = client_with_mock_rpc(state).await;
13334 let pool = test_pool();
13335
13336 let report = client.preflight(&pool.instrument_id).await.unwrap();
13337
13338 assert!(!report.ready);
13339 assert!(!report.chain_id_matches);
13340 assert!(
13341 report
13342 .issues
13343 .iter()
13344 .any(|issue| issue.contains("Chain ID mismatch"))
13345 );
13346 }
13347
13348 #[tokio::test]
13349 async fn preflight_not_ready_without_deployed_code() {
13350 let state = ready_rpc_state().with_response("eth_getCode", GET_CODE_EMPTY);
13351 let (client, _) = client_with_mock_rpc(state).await;
13352 let pool = test_pool();
13353
13354 let report = client.preflight(&pool.instrument_id).await.unwrap();
13355
13356 assert!(!report.ready);
13357 assert!(!report.pool.has_deployed_code);
13358 assert!(!report.routers[0].has_deployed_code);
13359 assert!(report.tokens.iter().all(|t| !t.has_deployed_code));
13360 assert!(
13361 report
13362 .issues
13363 .iter()
13364 .any(|issue| issue.contains("No deployed bytecode at router address"))
13365 );
13366 }
13367
13368 #[tokio::test]
13369 async fn preflight_not_ready_with_zero_balances_and_allowance() {
13370 let state = ready_rpc_state()
13371 .with_response("eth_getBalance", GET_BALANCE_ZERO)
13372 .with_call_response(BALANCE_OF_SELECTOR, CALL_ZERO)
13373 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO);
13374 let (client, _) = client_with_mock_rpc(state).await;
13375 let pool = test_pool();
13376
13377 let report = client.preflight(&pool.instrument_id).await.unwrap();
13378
13379 assert!(!report.ready);
13380 assert!(
13381 report
13382 .issues
13383 .iter()
13384 .any(|issue| issue.contains("Native currency balance is zero"))
13385 );
13386 assert!(
13387 report
13388 .issues
13389 .iter()
13390 .any(|issue| issue.contains("balance is zero"))
13391 );
13392 assert!(
13393 report
13394 .issues
13395 .iter()
13396 .any(|issue| issue.contains("No router allowance"))
13397 );
13398 }
13399
13400 #[tokio::test]
13401 async fn preflight_not_ready_when_fees_exceed_ceiling() {
13402 let addr = start_mock_rpc_server(ready_rpc_state()).await;
13403 let mut config = test_config(format!("http://{addr}"));
13404 config.max_fee_per_gas_wei = 1;
13405 let pool = test_pool();
13406 let client = test_client_from_config(config, pool.clone());
13407
13408 let report = client.preflight(&pool.instrument_id).await.unwrap();
13409
13410 assert!(!report.ready);
13411 assert!(!report.fee_within_ceiling);
13412 assert_eq!(report.derived_max_fee_per_gas_wei, 130_000_000);
13413 assert!(
13414 report
13415 .issues
13416 .iter()
13417 .any(|issue| issue.contains("exceeds ceiling"))
13418 );
13419 }
13420
13421 #[rstest]
13422 fn resolve_pool_rejects_unknown_pool() {
13423 let client = test_client("http://127.0.0.1:1".to_string());
13424 let unknown: InstrumentId = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45.Arbitrum:UniswapV3"
13425 .parse()
13426 .unwrap();
13427
13428 let error = client.resolve_pool(&unknown).unwrap_err();
13429
13430 assert!(error.to_string().contains("Unknown pool"), "was: {error}");
13431 }
13432
13433 #[rstest]
13434 fn resolve_pool_rejects_mismatched_chain() {
13435 let client = test_client("http://127.0.0.1:1".to_string());
13436 let ethereum_pool: InstrumentId =
13437 "0xC6962004f452bE9203591991D15f6b388e09E8D0.Ethereum:UniswapV3"
13438 .parse()
13439 .unwrap();
13440
13441 let error = client.resolve_pool(ðereum_pool).unwrap_err();
13442
13443 assert!(
13444 error
13445 .to_string()
13446 .contains("does not match the client chain"),
13447 "was: {error}"
13448 );
13449 }
13450
13451 #[rstest]
13452 fn resolve_pool_rejects_unsupported_dex() {
13453 let client = test_client("http://127.0.0.1:1".to_string());
13454 let v4_pool: InstrumentId = "0xC6962004f452bE9203591991D15f6b388e09E8D0.Arbitrum:UniswapV4"
13455 .parse()
13456 .unwrap();
13457
13458 let error = client.resolve_pool(&v4_pool).unwrap_err();
13459
13460 assert!(
13461 error.to_string().contains("only UniswapV3 is supported"),
13462 "was: {error}"
13463 );
13464 }
13465
13466 #[rstest]
13467 fn resolve_pool_rejects_pool_id_identifier() {
13468 let client = test_client("http://127.0.0.1:1".to_string());
13469 let pool_id: InstrumentId =
13470 "0x0000000000000000000000000000000000000000000000000000000000000000.Arbitrum:UniswapV3"
13471 .parse()
13472 .unwrap();
13473
13474 let error = client.resolve_pool(&pool_id).unwrap_err();
13475
13476 assert!(
13477 error
13478 .to_string()
13479 .contains("only address identifiers are supported"),
13480 "was: {error}"
13481 );
13482 }
13483
13484 #[rstest]
13485 fn resolve_pool_rejects_ambiguous_token_priority() {
13486 let chain = Arc::new(chains::ARBITRUM.clone());
13487 let dex = Arc::clone(&UNISWAP_V3.dex);
13488 let token_a = Token::new(
13489 Arc::clone(&chain),
13490 address!("1111111111111111111111111111111111111111"),
13491 "Token A".to_string(),
13492 "TOKA".to_string(),
13493 18,
13494 );
13495 let token_b = Token::new(
13496 Arc::clone(&chain),
13497 address!("2222222222222222222222222222222222222222"),
13498 "Token B".to_string(),
13499 "TOKB".to_string(),
13500 18,
13501 );
13502 let pool = Pool::new(
13503 chain,
13504 dex,
13505 address!("3333333333333333333333333333333333333333"),
13506 PoolIdentifier::from_address(address!("3333333333333333333333333333333333333333")),
13507 55_000_000,
13508 token_a,
13509 token_b,
13510 Some(500),
13511 Some(10),
13512 UnixNanos::default(),
13513 );
13514 let client =
13515 test_client_from_config(test_config("http://127.0.0.1:1".to_string()), pool.clone());
13516
13517 let error = client.resolve_pool(&pool.instrument_id).unwrap_err();
13518
13519 assert!(error.to_string().contains("ambiguous"), "was: {error}");
13520 }
13521
13522 #[rstest]
13523 fn new_parses_pair_specific_quote_spend_limits_with_distinct_precisions() {
13524 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13525 config.quote_spend_limits = Some(vec![
13526 quote_spend_limit(WETH, USDC, 18, &U256::MAX.to_string()),
13527 quote_spend_limit(USDC, WETH, 6, "1000000000"),
13528 ]);
13529
13530 let client = test_client_from_config(config, test_pool());
13531 let sell_ceiling = client
13532 .transaction_limits
13533 .quote_spend_limits
13534 .get(&(WETH_ADDRESS, USDC_ADDRESS))
13535 .unwrap();
13536 let buy_ceiling = client
13537 .transaction_limits
13538 .quote_spend_limits
13539 .get(&(USDC_ADDRESS, WETH_ADDRESS))
13540 .unwrap();
13541
13542 assert_eq!(sell_ceiling.spend_token, WETH_ADDRESS);
13543 assert_eq!(sell_ceiling.spend_token_decimals, 18);
13544 assert_eq!(sell_ceiling.max_amount, U256::MAX);
13545 assert_eq!(buy_ceiling.spend_token, USDC_ADDRESS);
13546 assert_eq!(buy_ceiling.spend_token_decimals, 6);
13547 assert_eq!(buy_ceiling.max_amount, U256::from(1_000_000_000u64));
13548 }
13549
13550 #[rstest]
13551 fn new_rejects_quote_spend_limit_token_pair_mismatch() {
13552 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13553 config.quote_spend_limits.as_mut().unwrap()[0].spend_token = WETH.to_string();
13554
13555 let error = test_client_result(config, test_pool()).unwrap_err();
13556
13557 assert!(
13558 error
13559 .to_string()
13560 .contains("`spend_token` must match `token_in`"),
13561 "was: {error}"
13562 );
13563 }
13564
13565 #[rstest]
13566 fn new_rejects_quote_spend_limit_pair_outside_allowlist() {
13567 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13568 config.quote_spend_limits = Some(vec![quote_spend_limit(
13569 USDC,
13570 "0x1111111111111111111111111111111111111111",
13571 6,
13572 "1000000000",
13573 )]);
13574
13575 let error = test_client_result(config, test_pool()).unwrap_err();
13576
13577 assert!(
13578 error
13579 .to_string()
13580 .contains("is not in the `allowed_token_pairs` allowlist"),
13581 "was: {error}"
13582 );
13583 }
13584
13585 #[rstest]
13586 fn new_rejects_duplicate_quote_spend_pairs() {
13587 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13588 config.quote_spend_limits = Some(vec![
13589 quote_spend_limit(USDC, WETH, 6, "1000000000"),
13590 quote_spend_limit(USDC, WETH, 6, "2000000000"),
13591 ]);
13592
13593 let error = test_client_result(config, test_pool()).unwrap_err();
13594
13595 assert!(
13596 error
13597 .to_string()
13598 .contains("Duplicate quote spend limit for token pair"),
13599 "was: {error}"
13600 );
13601 }
13602
13603 #[rstest]
13604 #[case::empty("")]
13605 #[case::signed("-1")]
13606 #[case::fractional("1.5")]
13607 #[case::hexadecimal("0x10")]
13608 #[case::overflow(
13609 "115792089237316195423570985008687907853269984665640564039457584007913129639936"
13610 )]
13611 fn new_rejects_invalid_quote_spend_max_amount(#[case] max_amount: &str) {
13612 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13613 config.quote_spend_limits.as_mut().unwrap()[0].max_amount = max_amount.to_string();
13614
13615 let error = test_client_result(config, test_pool()).unwrap_err();
13616
13617 assert!(
13618 error.to_string().contains("Quote spend limit `max_amount`"),
13619 "was: {error}"
13620 );
13621 }
13622
13623 #[rstest]
13624 #[case::allowed_token_pairs("allowed_token_pairs")]
13625 #[case::slippage_bps("slippage_bps")]
13626 #[case::max_slippage_bps("max_slippage_bps")]
13627 #[case::max_order_amount("max_order_amount")]
13628 #[case::deadline_seconds("deadline_seconds")]
13629 #[case::max_quote_age_blocks("max_quote_age_blocks")]
13630 #[case::receipt_timeout_secs("receipt_timeout_secs")]
13631 fn new_rejects_each_missing_transaction_limit(#[case] missing: &str) {
13632 let mut config = test_config("http://127.0.0.1:1".to_string());
13633 match missing {
13634 "allowed_token_pairs" => config.allowed_token_pairs = None,
13635 "slippage_bps" => config.slippage_bps = None,
13636 "max_slippage_bps" => config.max_slippage_bps = None,
13637 "max_order_amount" => config.max_order_amount = None,
13638 "deadline_seconds" => config.deadline_seconds = None,
13639 "max_quote_age_blocks" => config.max_quote_age_blocks = None,
13640 "receipt_timeout_secs" => config.receipt_timeout_secs = None,
13641 _ => unreachable!(),
13642 }
13643
13644 let error = test_client_result(config, test_pool()).unwrap_err();
13645
13646 assert_eq!(
13647 error.to_string(),
13648 "Blockchain execution transaction limits are required: allowed_token_pairs, slippage_bps, max_slippage_bps, max_order_amount, deadline_seconds, max_quote_age_blocks, receipt_timeout_secs"
13649 );
13650 }
13651
13652 #[rstest]
13653 fn new_rejects_empty_router_allowlist() {
13654 let mut config = test_config("http://127.0.0.1:1".to_string());
13655 config.router_addresses = Vec::new();
13656
13657 let error = test_client_result(config, test_pool()).unwrap_err();
13658
13659 assert!(
13660 error.to_string().contains("at least one router address"),
13661 "was: {error}"
13662 );
13663 }
13664
13665 #[tokio::test]
13666 async fn wrap_refuses_without_durable_store() {
13667 let (mut client, _) = client_with_mock_rpc(ready_rpc_state()).await;
13668 client.core.set_connected();
13669
13670 let error = client.wrap(U256::from(1_000u64)).await.unwrap_err();
13671
13672 assert!(
13673 error.to_string().contains("No durable store configured"),
13674 "was: {error}"
13675 );
13676 }
13677
13678 #[tokio::test]
13679 async fn approve_rejects_router_outside_allowlist() {
13680 let (mut client, state) = client_with_mock_rpc(ready_rpc_state()).await;
13681
13682 let error = client
13683 .approve(
13684 WETH_ADDRESS,
13685 U256::from(1_000u64),
13686 address!("68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"),
13687 )
13688 .await
13689 .err()
13690 .unwrap();
13691
13692 assert!(
13693 error
13694 .to_string()
13695 .contains("not in the configured `router_addresses` allowlist"),
13696 "was: {error}"
13697 );
13698 assert!(state.recorded_requests().is_empty());
13699 }
13700
13701 #[tokio::test]
13702 async fn approve_rejects_token_outside_input_allowlist() {
13703 let (mut client, state) = client_with_mock_rpc(ready_rpc_state()).await;
13704
13705 let error = client
13706 .approve(USDC_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
13707 .await
13708 .unwrap_err();
13709
13710 assert!(
13711 error
13712 .to_string()
13713 .contains("is not an input token in the configured `allowed_token_pairs`"),
13714 "was: {error}"
13715 );
13716 assert!(state.recorded_requests().is_empty());
13717 }
13718
13719 #[tokio::test]
13720 async fn in_flight_guard_rejects_second_transaction() {
13721 let (mut client, _) = client_with_mock_rpc(ready_rpc_state()).await;
13722 client.core.set_connected();
13723 *client.in_flight.lock() = Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
13724 intent_id: 1,
13725 nonce: 7,
13726 tx_hash: B256::ZERO,
13727 purpose: TransactionPurpose::Wrap,
13728 }));
13729
13730 let error = client.wrap(U256::from(1_000u64)).await.unwrap_err();
13731
13732 assert!(
13733 error.to_string().contains("still awaiting finality"),
13734 "was: {error}"
13735 );
13736 }
13737
13738 #[tokio::test]
13739 async fn wrap_rejects_zero_amount() {
13740 let (mut client, _) = client_with_mock_rpc(ready_rpc_state()).await;
13741
13742 let error = client.wrap(U256::ZERO).await.unwrap_err();
13743
13744 assert!(
13745 error.to_string().contains("Wrap amount must be positive"),
13746 "was: {error}"
13747 );
13748 }
13749
13750 #[tokio::test]
13751 async fn wrap_rejects_code_free_target_before_broadcast() {
13752 let state = execution_rpc_state().with_response("eth_getCode", GET_CODE_EMPTY);
13753 let Some((admin_pool, schema, mut client, state)) =
13754 execution_client_with_database("execution_wrap_code_free_test", state).await
13755 else {
13756 return;
13757 };
13758
13759 let error = client
13760 .wrap(U256::from(1_000_000_000_000_000u64))
13761 .await
13762 .unwrap_err();
13763
13764 assert!(
13765 error
13766 .to_string()
13767 .contains("pre-sign deployment manifest verification disagreed"),
13768 "was: {error}"
13769 );
13770 assert!(client.in_flight.lock().is_none());
13771 let requests = state.recorded_requests();
13772 assert_eq!(
13773 requests
13774 .iter()
13775 .filter(|request| request["method"] == "eth_getCode")
13776 .count(),
13777 3
13778 );
13779 assert!(
13780 requests
13781 .iter()
13782 .all(|request| request["method"] != "eth_sendRawTransaction")
13783 );
13784
13785 drop_execution_schema(&admin_pool, &schema).await;
13786 }
13787
13788 #[tokio::test]
13789 async fn wrap_rejects_unrelated_target_before_broadcast() {
13790 let state = execution_rpc_state().with_response("eth_call", CALL_EMPTY);
13791 let Some((admin_pool, schema, mut client, state)) =
13792 execution_client_with_database("execution_wrap_unrelated_test", state).await
13793 else {
13794 return;
13795 };
13796
13797 let error = client
13798 .wrap(U256::from(1_000_000_000_000_000u64))
13799 .await
13800 .unwrap_err();
13801
13802 assert!(
13803 error
13804 .to_string()
13805 .contains("pre-sign wrapped token probe verification is unavailable"),
13806 "was: {error}"
13807 );
13808 assert!(client.in_flight.lock().is_none());
13809 let requests = state.recorded_requests();
13810 assert!(
13811 requests
13812 .iter()
13813 .all(|request| request["method"] != "eth_sendRawTransaction")
13814 );
13815
13816 drop_execution_schema(&admin_pool, &schema).await;
13817 }
13818
13819 #[tokio::test]
13820 async fn wrap_rejects_included_transaction_without_balance_delta() {
13821 let state = broadcast_rpc_state().with_response_sequence("eth_call", &[CALL_BALANCE; 9]);
13822 let Some((admin_pool, schema, mut client, state)) =
13823 execution_client_with_database("execution_wrap_no_delta_test", state).await
13824 else {
13825 return;
13826 };
13827
13828 let error = client
13829 .wrap(U256::from(1_000_000_000_000_000u64))
13830 .await
13831 .unwrap_err();
13832
13833 assert!(
13834 error.to_string().contains("did not increase"),
13835 "was: {error}"
13836 );
13837 let in_flight = awaiting_in_flight(&client);
13838 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
13839 assert_eq!(
13840 execution_intent_markers(&admin_pool, &schema).await,
13841 vec![("wrap".into(), "broadcast".into(), false, true)]
13842 );
13843 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
13844 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
13845 )))
13846 .fetch_one(&admin_pool)
13847 .await
13848 .unwrap();
13849 assert_eq!(nonce_state, (7, 0));
13850 let broadcasts = state
13851 .recorded_requests()
13852 .into_iter()
13853 .filter(|request| request["method"] == "eth_sendRawTransaction")
13854 .count();
13855 assert_eq!(broadcasts, 1);
13856
13857 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000u64)).await;
13858 let block = finalized_wrap_block(expected_hash);
13859 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
13860 let restart_state = with_finalized_identity(
13861 execution_rpc_state()
13862 .with_response("eth_getTransactionReceipt", &receipt)
13863 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
13864 .with_response_sequence("eth_call", &[CALL_BALANCE; 6]),
13865 &block,
13866 &receipt,
13867 );
13868 let addr = start_mock_rpc_server(restart_state).await;
13869 let error = later_reconnect(client, format!("http://{addr}")).await;
13870 assert!(
13871 error.to_string().contains("did not increase"),
13872 "was: {error}"
13873 );
13874 assert_eq!(
13875 execution_intent_markers(&admin_pool, &schema).await,
13876 vec![("wrap".into(), "broadcast".into(), false, true)]
13877 );
13878 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
13879 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
13880 )))
13881 .fetch_one(&admin_pool)
13882 .await
13883 .unwrap();
13884 assert_eq!(nonce_state, (7, 0));
13885
13886 drop_execution_schema(&admin_pool, &schema).await;
13887 }
13888
13889 #[tokio::test]
13890 async fn wrap_reports_inclusion_when_postcondition_read_fails() {
13891 let state = broadcast_rpc_state().with_response_sequence(
13892 "eth_call",
13893 &[
13894 CALL_BALANCE,
13895 CALL_BALANCE,
13896 CALL_BALANCE,
13897 CALL_BALANCE,
13898 CALL_BALANCE,
13899 CALL_BALANCE,
13900 RPC_METHOD_NOT_FOUND,
13901 RPC_METHOD_NOT_FOUND,
13902 RPC_METHOD_NOT_FOUND,
13903 ],
13904 );
13905 let Some((admin_pool, schema, mut client, _)) =
13906 execution_client_with_database("execution_wrap_postcondition_rpc_test", state).await
13907 else {
13908 return;
13909 };
13910
13911 let error = client
13912 .wrap(U256::from(1_000_000_000_000_000u64))
13913 .await
13914 .unwrap_err();
13915
13916 let message = error.to_string();
13917 assert!(
13918 message.contains("failed to verify WETH balance after included transaction 0x"),
13919 "was: {message}"
13920 );
13921 assert!(message.contains("at block 30346561"), "was: {message}");
13922 let in_flight = awaiting_in_flight(&client);
13923 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
13924 assert_eq!(
13925 execution_intent_markers(&admin_pool, &schema).await,
13926 vec![("wrap".into(), "broadcast".into(), false, true)]
13927 );
13928
13929 drop_execution_schema(&admin_pool, &schema).await;
13930 }
13931
13932 #[tokio::test]
13933 async fn approve_rejects_false_return_before_broadcast() {
13934 let state = execution_rpc_state().with_response("eth_call", CALL_ZERO);
13935 let Some((admin_pool, schema, mut client, state)) =
13936 execution_client_with_database("execution_approve_false_test", state).await
13937 else {
13938 return;
13939 };
13940
13941 let error = client
13942 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
13943 .await
13944 .unwrap_err();
13945
13946 assert!(error.to_string().contains("returned false"), "was: {error}");
13947 assert!(client.in_flight.lock().is_none());
13948 let requests = state.recorded_requests();
13949 let approval_calls = requests
13950 .iter()
13951 .filter(|request| {
13952 request["method"] == "eth_call"
13953 && request["params"][0]["data"]
13954 .as_str()
13955 .is_some_and(|data| data.starts_with("0x095ea7b3"))
13956 })
13957 .collect::<Vec<_>>();
13958 assert_eq!(approval_calls.len(), 3);
13959 for request in approval_calls {
13960 assert_eq!(
13961 request["params"][0]["from"]
13962 .as_str()
13963 .unwrap()
13964 .parse::<Address>()
13965 .unwrap(),
13966 WALLET.parse::<Address>().unwrap()
13967 );
13968 assert_eq!(request["params"][1], FIXTURE_BLOCK_PARAM);
13969 }
13970 assert!(
13971 requests
13972 .iter()
13973 .all(|request| request["method"] != "eth_getTransactionCount")
13974 );
13975 assert!(
13976 requests
13977 .iter()
13978 .all(|request| request["method"] != "eth_sendRawTransaction")
13979 );
13980
13981 drop_execution_schema(&admin_pool, &schema).await;
13982 }
13983
13984 #[tokio::test]
13985 async fn approve_rejects_router_with_wrong_factory_before_signing() {
13986 let state = ready_rpc_state().with_call_response(FACTORY_SELECTOR, CALL_ZERO);
13987 let Some((admin_pool, schema, mut client, state)) =
13988 execution_client_with_database("execution_approve_factory_test", state).await
13989 else {
13990 return;
13991 };
13992
13993 let error = client
13994 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
13995 .await
13996 .unwrap_err();
13997
13998 assert!(
13999 error
14000 .to_string()
14001 .contains("pre-sign deployment manifest verification disagreed"),
14002 "was: {error}"
14003 );
14004 let requests = state.recorded_requests();
14005 assert!(
14006 requests
14007 .iter()
14008 .all(|request| request["method"] != "eth_getTransactionCount")
14009 );
14010 assert!(
14011 requests
14012 .iter()
14013 .all(|request| request["method"] != "eth_sendRawTransaction")
14014 );
14015
14016 drop_execution_schema(&admin_pool, &schema).await;
14017 }
14018
14019 #[tokio::test]
14020 async fn approve_rejects_router_with_wrong_weth_before_signing() {
14021 let state = ready_rpc_state().with_call_response(WETH9_SELECTOR, CALL_ZERO);
14022 let Some((admin_pool, schema, mut client, state)) =
14023 execution_client_with_database("execution_approve_weth_test", state).await
14024 else {
14025 return;
14026 };
14027
14028 let error = client
14029 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
14030 .await
14031 .unwrap_err();
14032
14033 assert!(
14034 error
14035 .to_string()
14036 .contains("pre-sign deployment manifest verification disagreed"),
14037 "was: {error}"
14038 );
14039 let requests = state.recorded_requests();
14040 assert!(
14041 requests
14042 .iter()
14043 .all(|request| request["method"] != "eth_getTransactionCount")
14044 );
14045 assert!(
14046 requests
14047 .iter()
14048 .all(|request| request["method"] != "eth_sendRawTransaction")
14049 );
14050
14051 drop_execution_schema(&admin_pool, &schema).await;
14052 }
14053
14054 #[tokio::test]
14055 async fn approve_rejects_nonzero_to_nonzero_transition_before_signing() {
14056 let state = ready_rpc_state();
14057 let Some((admin_pool, schema, mut client, state)) =
14058 execution_client_with_database("execution_approve_nonzero_test", state).await
14059 else {
14060 return;
14061 };
14062
14063 let error = client
14064 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
14065 .await
14066 .unwrap_err();
14067
14068 assert!(
14069 error.to_string().contains("approve zero before setting"),
14070 "was: {error}"
14071 );
14072 let requests = state.recorded_requests();
14073 assert!(
14074 requests
14075 .iter()
14076 .all(|request| request["method"] != "eth_getTransactionCount")
14077 );
14078 assert!(
14079 requests
14080 .iter()
14081 .all(|request| request["method"] != "eth_sendRawTransaction")
14082 );
14083
14084 drop_execution_schema(&admin_pool, &schema).await;
14085 }
14086
14087 #[tokio::test]
14088 async fn approve_zero_revokes_under_unlimited_policy() {
14089 let state = broadcast_rpc_state()
14090 .with_response("eth_call", CALL_BOOL_TRUE)
14091 .with_call_response_sequence(
14092 ALLOWANCE_SELECTOR,
14093 &[
14094 CALL_ALLOWANCE,
14095 CALL_ALLOWANCE,
14096 CALL_ALLOWANCE,
14097 CALL_ZERO,
14098 CALL_ZERO,
14099 CALL_ZERO,
14100 ],
14101 );
14102 let Some((admin_pool, schema, mut client, state)) =
14103 execution_client_with_database("execution_approve_revoke_test", state).await
14104 else {
14105 return;
14106 };
14107 client.config.unlimited_approval = true;
14108 client.transaction_limits.receipt_timeout_secs = TEST_TIMEOUT.as_secs();
14109
14110 let tx_hash = client
14111 .approve(WETH_ADDRESS, U256::ZERO, ROUTER_ADDRESS)
14112 .await
14113 .unwrap();
14114
14115 assert_eq!(tx_hash, expected_approve_tx_hash(U256::ZERO).await);
14116 let approve_data = state
14117 .recorded_requests()
14118 .into_iter()
14119 .find_map(|request| {
14120 (request["method"] == "eth_estimateGas")
14121 .then(|| request["params"][0]["data"].as_str().map(str::to_owned))
14122 .flatten()
14123 })
14124 .unwrap();
14125 assert!(approve_data.starts_with("0x095ea7b3"));
14126 assert!(approve_data.ends_with(&"0".repeat(64)));
14127 assert_eq!(
14128 state
14129 .recorded_requests()
14130 .iter()
14131 .filter(|request| {
14132 request["method"] == "eth_call"
14133 && request["params"][0]["data"]
14134 .as_str()
14135 .is_some_and(|data| data.starts_with(FACTORY_SELECTOR))
14136 })
14137 .count(),
14138 12
14139 );
14140 assert_eq!(
14141 execution_intent_markers(&admin_pool, &schema).await,
14142 vec![("approve".into(), "finalized".into(), true, false)]
14143 );
14144
14145 drop_execution_schema(&admin_pool, &schema).await;
14146 }
14147
14148 #[tokio::test]
14149 async fn approve_accepts_empty_return_with_sufficient_allowance() {
14150 let state = broadcast_rpc_state()
14151 .with_response("eth_call", CALL_EMPTY)
14152 .with_call_response_sequence(
14153 ALLOWANCE_SELECTOR,
14154 &[
14155 CALL_ZERO,
14156 CALL_ZERO,
14157 CALL_ZERO,
14158 CALL_ALLOWANCE_1000,
14159 CALL_ALLOWANCE_1000,
14160 CALL_ALLOWANCE_1000,
14161 ],
14162 );
14163 let Some((admin_pool, schema, mut client, _)) =
14164 execution_client_with_database("execution_approve_empty_test", state).await
14165 else {
14166 return;
14167 };
14168
14169 let tx_hash = client
14170 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
14171 .await
14172 .unwrap();
14173
14174 let record = client
14175 .cache
14176 .get_execution_transaction(42161, &tx_hash.to_string())
14177 .await
14178 .unwrap()
14179 .unwrap();
14180 assert_eq!(record.purpose, "approve");
14181 assert_eq!(record.status, "finalized");
14182 assert!(client.in_flight.lock().is_none());
14183 assert_eq!(
14184 execution_intent_markers(&admin_pool, &schema).await,
14185 vec![("approve".into(), "finalized".into(), true, false)]
14186 );
14187
14188 drop_execution_schema(&admin_pool, &schema).await;
14189 }
14190
14191 #[tokio::test]
14192 async fn approve_rejects_empty_return_with_insufficient_allowance() {
14193 let state = broadcast_rpc_state()
14194 .with_response("eth_call", CALL_EMPTY)
14195 .with_call_response_sequence(ALLOWANCE_SELECTOR, &[CALL_ZERO; 6]);
14196 let Some((admin_pool, schema, mut client, _)) =
14197 execution_client_with_database("execution_approve_insufficient_test", state).await
14198 else {
14199 return;
14200 };
14201
14202 let error = client
14203 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
14204 .await
14205 .unwrap_err();
14206
14207 assert!(
14208 error
14209 .to_string()
14210 .contains("does not equal the requested amount"),
14211 "was: {error}"
14212 );
14213 let in_flight = awaiting_in_flight(&client);
14214 assert_eq!(in_flight.purpose, TransactionPurpose::Approve);
14215 assert_eq!(
14216 execution_intent_markers(&admin_pool, &schema).await,
14217 vec![("approve".into(), "broadcast".into(), false, true)]
14218 );
14219 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
14220 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
14221 )))
14222 .fetch_one(&admin_pool)
14223 .await
14224 .unwrap();
14225 assert_eq!(nonce_state, (7, 0));
14226
14227 let expected_hash = expected_approve_tx_hash(U256::from(1_000u64)).await;
14228 let block = finalized_approve_block(expected_hash, U256::from(1_000u64));
14229 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
14230 let restart_state = with_finalized_identity(
14231 execution_rpc_state()
14232 .with_response("eth_getTransactionReceipt", &receipt)
14233 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
14234 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO),
14235 &block,
14236 &receipt,
14237 );
14238 let addr = start_mock_rpc_server(restart_state).await;
14239 let error = later_reconnect(client, format!("http://{addr}")).await;
14240 assert!(
14241 error
14242 .to_string()
14243 .contains("does not equal the requested amount"),
14244 "was: {error}"
14245 );
14246 assert_eq!(
14247 execution_intent_markers(&admin_pool, &schema).await,
14248 vec![("approve".into(), "broadcast".into(), false, true)]
14249 );
14250 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
14251 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
14252 )))
14253 .fetch_one(&admin_pool)
14254 .await
14255 .unwrap();
14256 assert_eq!(nonce_state, (7, 0));
14257
14258 drop_execution_schema(&admin_pool, &schema).await;
14259 }
14260
14261 #[tokio::test]
14262 async fn approve_reports_inclusion_when_postcondition_read_fails() {
14263 let state = broadcast_rpc_state()
14264 .with_response("eth_call", CALL_BOOL_TRUE)
14265 .with_call_response_sequence(
14266 ALLOWANCE_SELECTOR,
14267 &[
14268 CALL_ZERO,
14269 CALL_ZERO,
14270 CALL_ZERO,
14271 RPC_METHOD_NOT_FOUND,
14272 RPC_METHOD_NOT_FOUND,
14273 RPC_METHOD_NOT_FOUND,
14274 ],
14275 );
14276 let Some((admin_pool, schema, mut client, _)) =
14277 execution_client_with_database("execution_approve_postcondition_rpc_test", state).await
14278 else {
14279 return;
14280 };
14281
14282 let error = client
14283 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
14284 .await
14285 .unwrap_err();
14286
14287 let message = error.to_string();
14288 assert!(
14289 message.contains("failed to verify router allowance after included transaction 0x"),
14290 "was: {message}"
14291 );
14292 assert!(message.contains("at block 30346561"), "was: {message}");
14293 let in_flight = awaiting_in_flight(&client);
14294 assert_eq!(in_flight.purpose, TransactionPurpose::Approve);
14295 assert_eq!(
14296 execution_intent_markers(&admin_pool, &schema).await,
14297 vec![("approve".into(), "broadcast".into(), false, true)]
14298 );
14299
14300 drop_execution_schema(&admin_pool, &schema).await;
14301 }
14302
14303 #[tokio::test]
14304 async fn wallet_balance_refresh_replaces_complete_snapshot_with_exact_precision() {
14305 let state = execution_rpc_state()
14306 .with_response_sequence("eth_getBalance", &[GET_BALANCE, GET_BALANCE_ZERO])
14307 .with_response_sequence(
14308 "eth_call",
14309 &[
14310 CALL_BALANCE_WETH,
14311 CALL_BALANCE_USDC,
14312 CALL_BALANCE_WETH_UPDATED,
14313 CALL_BALANCE_USDC_UPDATED,
14314 ],
14315 );
14316 let (mut client, _, _) =
14317 client_with_token_mock_rpc(state, "BLOCKCHAIN_TEST_BALANCE_REPLACE").await;
14318 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14319 replace_exec_event_sender(sender);
14320 client.start().unwrap();
14321
14322 client.refresh_wallet_balances().await.unwrap();
14323 let balances = client.wallet_balance.lock().as_account_balances().unwrap();
14324
14325 assert_eq!(balances.len(), 3);
14326 assert_eq!(balances[0].currency.code, "ETH");
14327 assert_eq!(balances[0].currency.name, "Ethereum");
14328 assert_eq!(balances[0].currency.precision, 18);
14329 assert_eq!(balances[0].total.raw(), 1_000_000_000_000_000_000);
14330 assert_eq!(balances[0].free, balances[0].total);
14331 assert_eq!(balances[0].locked, Money::zero(balances[0].currency));
14332 assert_eq!(balances[1].currency.code, "WETH");
14333 assert_eq!(balances[1].currency.name, "Wrapped Ether");
14334 assert_eq!(balances[1].currency.precision, 18);
14335 assert_eq!(balances[1].total.raw(), 1_234_567_890_123_456_789);
14336 assert_eq!(balances[1].free, balances[1].total);
14337 assert_eq!(balances[1].locked, Money::zero(balances[1].currency));
14338 assert_eq!(balances[2].currency.code, "USDC");
14339 assert_eq!(balances[2].currency.name, "USD Coin");
14340 assert_eq!(balances[2].currency.precision, 6);
14341 assert_eq!(balances[2].total.raw(), 9_876_543_210_000_000_000);
14342 assert_eq!(balances[2].free, balances[2].total);
14343 assert_eq!(balances[2].locked, Money::zero(balances[2].currency));
14344
14345 client.refresh_wallet_balances().await.unwrap();
14346 let balances = client.wallet_balance.lock().as_account_balances().unwrap();
14347
14348 assert_eq!(balances.len(), 3);
14349 assert_eq!(client.wallet_balance.lock().token_balances.len(), 2);
14350 assert_eq!(balances[0].total.raw(), 0);
14351 assert_eq!(balances[1].total.raw(), 2_000_000_000_000_000_000);
14352 assert_eq!(balances[2].total.raw(), 12_345_670_000_000_000);
14353 }
14354
14355 #[allow(unsafe_code)] #[tokio::test]
14357 async fn failed_connect_refresh_retains_snapshot_and_publishes_nothing() {
14358 let state = execution_rpc_state()
14359 .with_response_sequence("eth_getBalance", &[GET_BALANCE, GET_BALANCE_ZERO])
14360 .with_response_sequence(
14361 "eth_call",
14362 &[
14363 CALL_BALANCE_WETH,
14364 CALL_BALANCE_USDC,
14365 CALL_BALANCE_WETH_UPDATED,
14366 RPC_METHOD_NOT_FOUND,
14367 ],
14368 );
14369 let (mut client, _, _) =
14370 client_with_token_mock_rpc(state, "BLOCKCHAIN_TEST_BALANCE_ATOMIC").await;
14371 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
14372 replace_exec_event_sender(sender);
14373 client.start().unwrap();
14374 client.refresh_wallet_balances().await.unwrap();
14375 let retained = client.wallet_balance.lock().as_account_balances().unwrap();
14376 receiver.try_recv().unwrap();
14377 unsafe { std::env::set_var("BLOCKCHAIN_TEST_BALANCE_ATOMIC", TEST_PRIVATE_KEY) };
14379
14380 let error = client.connect().await.unwrap_err();
14381 let failed_address = Address::from_str(USDC).unwrap();
14382
14383 assert!(
14384 error.to_string().contains(&format!(
14385 "failed to fetch token balance for {failed_address}"
14386 )),
14387 "was: {error}"
14388 );
14389 assert!(!client.is_connected());
14390 assert!(client.signer.is_none());
14391 assert_eq!(
14392 client.wallet_balance.lock().as_account_balances().unwrap(),
14393 retained
14394 );
14395 assert!(matches!(
14396 receiver.try_recv(),
14397 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
14398 ));
14399 }
14400
14401 #[allow(unsafe_code)] #[tokio::test]
14403 async fn failed_connect_publication_retains_snapshot() {
14404 let state = execution_rpc_state()
14405 .with_response_sequence("eth_getBalance", &[GET_BALANCE, GET_BALANCE_ZERO])
14406 .with_response_sequence(
14407 "eth_call",
14408 &[
14409 CALL_BALANCE_WETH,
14410 CALL_BALANCE_USDC,
14411 CALL_BALANCE_WETH_UPDATED,
14412 CALL_BALANCE_USDC_UPDATED,
14413 ],
14414 );
14415 let (mut client, _, _) =
14416 client_with_token_mock_rpc(state, "BLOCKCHAIN_TEST_BALANCE_PUBLICATION").await;
14417 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
14418 replace_exec_event_sender(sender);
14419 client.start().unwrap();
14420 client.refresh_wallet_balances().await.unwrap();
14421 let retained = client.wallet_balance.lock().as_account_balances().unwrap();
14422 receiver.try_recv().unwrap();
14423 drop(receiver);
14424 unsafe { std::env::set_var("BLOCKCHAIN_TEST_BALANCE_PUBLICATION", TEST_PRIVATE_KEY) };
14426
14427 let error = client.connect().await.unwrap_err();
14428
14429 assert!(
14430 error.to_string().contains("Failed to send account state"),
14431 "was: {error}"
14432 );
14433 assert!(!client.is_connected());
14434 assert!(client.signer.is_none());
14435 assert_eq!(
14436 client.wallet_balance.lock().as_account_balances().unwrap(),
14437 retained
14438 );
14439 }
14440
14441 #[allow(unsafe_code)] #[tokio::test]
14443 async fn connect_and_repeated_query_publish_wallet_account_state() {
14444 let state = execution_rpc_state()
14445 .with_response_sequence("eth_call", &[CALL_BALANCE_WETH, CALL_BALANCE_USDC]);
14446 let (mut client, state, cache) =
14447 client_with_token_mock_rpc(state, "BLOCKCHAIN_TEST_ACCOUNT_STATE").await;
14448 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
14449 replace_exec_event_sender(sender);
14450 client.start().unwrap();
14451 unsafe { std::env::set_var("BLOCKCHAIN_TEST_ACCOUNT_STATE", TEST_PRIVATE_KEY) };
14453
14454 client.connect().await.unwrap();
14455
14456 let ExecutionEvent::Account(connected) = receiver.try_recv().unwrap() else {
14457 panic!("expected account state event")
14458 };
14459 assert_eq!(connected.account_id, AccountId::from("BLOCKCHAIN-001"));
14460 assert_eq!(connected.account_type, AccountType::Wallet);
14461 assert_eq!(connected.base_currency, None);
14462 assert_eq!(connected.balances.len(), 3);
14463 assert!(connected.margins.is_empty());
14464 assert!(connected.is_reported);
14465 cache.borrow_mut().update_account_state(&connected).unwrap();
14466
14467 let account = client.get_account().unwrap();
14468 assert!(matches!(account, AccountAny::Wallet(_)));
14469 assert_eq!(account.id(), AccountId::from("BLOCKCHAIN-001"));
14470 assert_eq!(account.last_event(), Some(connected.clone()));
14471
14472 let requests_before = state.recorded_requests().len();
14473 let query = || {
14474 QueryAccount::new(
14475 TraderId::from("TRADER-001"),
14476 Some(ClientId::from("BLOCKCHAIN-001")),
14477 AccountId::from("BLOCKCHAIN-001"),
14478 UUID4::new(),
14479 UnixNanos::default(),
14480 None,
14481 None,
14482 )
14483 };
14484 client.query_account(query()).unwrap();
14485 client.query_account(query()).unwrap();
14486
14487 let ExecutionEvent::Account(first_query) = receiver.try_recv().unwrap() else {
14488 panic!("expected first query account state event")
14489 };
14490 let ExecutionEvent::Account(second_query) = receiver.try_recv().unwrap() else {
14491 panic!("expected second query account state event")
14492 };
14493 assert!(connected.has_same_balances_and_margins(&first_query));
14494 assert!(first_query.has_same_balances_and_margins(&second_query));
14495 assert_eq!(first_query.account_id, connected.account_id);
14496 assert_eq!(first_query.account_type, connected.account_type);
14497 assert_eq!(first_query.base_currency, connected.base_currency);
14498 assert_eq!(first_query.is_reported, connected.is_reported);
14499 assert_ne!(first_query.event_id, second_query.event_id);
14500 assert_eq!(state.recorded_requests().len(), requests_before);
14501
14502 client.stop().unwrap();
14503 assert!(client.core.is_stopped());
14504 assert!(!client.is_connected());
14505 assert!(client.signer.is_none());
14506 }
14507
14508 #[tokio::test]
14509 async fn connect_rejects_on_chain_mismatch() {
14510 let state = ready_rpc_state().with_response("eth_chainId", CHAIN_ID_ETHEREUM);
14511 let (mut client, _) = client_with_mock_rpc(state).await;
14512
14513 let error = client.connect().await.unwrap_err();
14514
14515 assert!(
14516 error
14517 .to_string()
14518 .contains("Blockchain chain ID verification disagreed"),
14519 "was: {error}"
14520 );
14521 }
14522
14523 #[allow(unsafe_code)] #[tokio::test]
14525 async fn connect_rejects_on_signer_wallet_mismatch() {
14526 let addr = start_mock_rpc_server(ready_rpc_state()).await;
14527 let config = test_config_with_signer_env(
14528 format!("http://{addr}"),
14529 "BLOCKCHAIN_TEST_PRIVATE_KEY_MISMATCH",
14530 );
14531 let mut client = test_client_from_config(config, test_pool());
14532 unsafe {
14535 std::env::set_var(
14536 "BLOCKCHAIN_TEST_PRIVATE_KEY_MISMATCH",
14537 "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
14538 );
14539 };
14540
14541 let error = client.connect().await.unwrap_err();
14542
14543 assert!(
14544 error
14545 .to_string()
14546 .contains("does not match configured wallet address"),
14547 "was: {error}"
14548 );
14549 }
14550
14551 #[allow(unsafe_code)] #[tokio::test]
14553 async fn connect_rejects_missing_trace_capability_before_signer_load() {
14554 let state = ready_rpc_state().with_parameter_response(
14555 "debug_traceTransaction",
14556 &B256::ZERO.to_string(),
14557 RPC_METHOD_NOT_FOUND,
14558 );
14559 let addr = start_mock_rpc_server(state.clone()).await;
14560 let config = test_config_with_signer_env(
14561 format!("http://{addr}"),
14562 "BLOCKCHAIN_TEST_TRACE_CAPABILITY",
14563 );
14564 let mut client = test_client_from_config(config, test_pool());
14565 unsafe { std::env::set_var("BLOCKCHAIN_TEST_TRACE_CAPABILITY", TEST_PRIVATE_KEY) };
14567
14568 let error = client.connect().await.unwrap_err();
14569
14570 assert!(
14571 error
14572 .to_string()
14573 .contains("Blockchain call trace capability verification is locally invalid"),
14574 "was: {error}"
14575 );
14576 assert!(client.signer.is_none());
14577 assert!(
14578 state
14579 .recorded_requests()
14580 .iter()
14581 .all(|request| request["method"] != "eth_getBalance")
14582 );
14583 }
14584
14585 #[allow(unsafe_code)] #[tokio::test]
14587 async fn connect_initializes_signer_from_env() {
14588 let addr = start_mock_rpc_server(ready_rpc_state()).await;
14589 let config =
14590 test_config_with_signer_env(format!("http://{addr}"), "BLOCKCHAIN_TEST_PRIVATE_KEY_OK");
14591 let mut client = test_client_from_config(config, test_pool());
14592 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14593 replace_exec_event_sender(sender);
14594 client.start().unwrap();
14595 unsafe { std::env::set_var("BLOCKCHAIN_TEST_PRIVATE_KEY_OK", TEST_PRIVATE_KEY) };
14597
14598 client.connect().await.unwrap();
14599
14600 let signer = client.signer.as_ref().unwrap();
14601 assert_eq!(
14602 signer.address(),
14603 address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266")
14604 );
14605 }
14606
14607 #[allow(unsafe_code)] #[tokio::test]
14609 async fn reconnect_resumes_at_the_durable_finalized_tip() {
14610 let Some((admin_pool, pg_config)) =
14611 connect_test_postgres("verification ledger resume").await
14612 else {
14613 return;
14614 };
14615 let schema = format!("verification_ledger_resume_{}", std::process::id());
14616 setup_execution_schema(&admin_pool, &schema).await;
14617 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
14618 let options = options.options([("search_path", schema.clone())]);
14619 let database = connect_test_database(options).await.unwrap();
14620 database
14621 .ensure_execution_transaction_schema()
14622 .await
14623 .unwrap();
14624
14625 let state = ready_rpc_state();
14626 let addr = start_mock_rpc_server(state.clone()).await;
14627 let config = test_config_with_signer_env(
14628 format!("http://{addr}"),
14629 "BLOCKCHAIN_TEST_VERIFICATION_RESUME",
14630 );
14631 let mut client = test_client_from_config(config, test_pool());
14632 client.cache.database = Some(database);
14633 protect_test_storage(&mut client, &schema).await;
14634 let finalized_headers = [
14635 ExecutionVerifiedHeader {
14636 number: FIXTURE_BLOCK,
14637 hash: FIXTURE_BLOCK_HASH.to_string(),
14638 parent_hash: "0x0000000000000000000000000000000000000000000000000000000000000001"
14639 .to_string(),
14640 timestamp: FIXTURE_BLOCK_TIMESTAMP,
14641 base_fee_per_gas: Some(100_000_000),
14642 },
14643 ExecutionVerifiedHeader {
14644 number: FIXTURE_BLOCK + 1,
14645 hash: B256::from([0x22; 32]).to_string(),
14646 parent_hash: FIXTURE_BLOCK_HASH.to_string(),
14647 timestamp: FIXTURE_BLOCK_TIMESTAMP + 1,
14648 base_fee_per_gas: Some(100_000_000),
14649 },
14650 ExecutionVerifiedHeader {
14651 number: FIXTURE_BLOCK + 2,
14652 hash: B256::from([0x33; 32]).to_string(),
14653 parent_hash: B256::from([0x22; 32]).to_string(),
14654 timestamp: FIXTURE_BLOCK_TIMESTAMP + 2,
14655 base_fee_per_gas: Some(100_000_000),
14656 },
14657 ];
14658 initialize_test_verification_ledger_with_headers(&client, &finalized_headers).await;
14659 let verification = client.config.verification.as_ref().unwrap();
14660 let position = client
14661 .cache
14662 .database
14663 .as_ref()
14664 .unwrap()
14665 .load_execution_verification_position(
14666 42_161,
14667 WALLET,
14668 &verification.manifest_version,
14669 &verification.manifest_digest,
14670 )
14671 .await
14672 .unwrap()
14673 .unwrap();
14674 let resume = client
14675 .cache
14676 .database
14677 .as_ref()
14678 .unwrap()
14679 .load_execution_verification_resume(
14680 42_161,
14681 WALLET,
14682 &verification.manifest_version,
14683 &verification.manifest_digest,
14684 )
14685 .await
14686 .unwrap()
14687 .unwrap();
14688 assert_eq!(position.next_canonical_nonce, 7);
14689 assert_eq!(position.revision, 0);
14690 assert_eq!(position.finalized_tip, finalized_headers[2]);
14691 assert_eq!(resume.next_canonical_nonce, 7);
14692 assert_eq!(resume.revision, 0);
14693 assert_eq!(resume.finalized_headers, finalized_headers);
14694
14695 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14696 replace_exec_event_sender(sender);
14697 client.start().unwrap();
14698 unsafe { std::env::set_var("BLOCKCHAIN_TEST_VERIFICATION_RESUME", TEST_PRIVATE_KEY) };
14700
14701 client.connect().await.unwrap();
14702
14703 let skipped_height = format!("0x{:x}", FIXTURE_BLOCK + 1);
14704 assert!(client.is_connected());
14705 assert!(state.recorded_requests().iter().all(|request| {
14706 request["method"] != "eth_getBlockByNumber"
14707 || request["params"][0].as_str() != Some(&skipped_height)
14708 }));
14709 let header_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
14710 "SELECT COUNT(*) FROM {schema}.execution_verified_finalized_header"
14711 )))
14712 .fetch_one(&admin_pool)
14713 .await
14714 .unwrap();
14715 assert_eq!(header_count, 3);
14716
14717 drop(client);
14718 drop_execution_schema(&admin_pool, &schema).await;
14719 }
14720
14721 #[tokio::test]
14722 async fn disconnect_revokes_signer_and_blocks_execution() {
14723 let (mut client, state) = client_with_mock_rpc(ready_rpc_state()).await;
14724 client.core.set_connected();
14725 client.signer = Some(Arc::new(
14726 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
14727 ));
14728
14729 client.disconnect().await.unwrap();
14730 let error = client.wrap(U256::from(1_000u64)).await.unwrap_err();
14731
14732 assert!(!client.is_connected());
14733 assert!(client.signer.is_none());
14734 assert!(
14735 error.to_string().contains("is not connected"),
14736 "was: {error}"
14737 );
14738 assert!(state.recorded_requests().is_empty());
14739 }
14740
14741 #[allow(unsafe_code)] #[tokio::test]
14743 async fn connect_releases_stale_preparing_claim_after_tasks_finish() {
14744 let addr = start_mock_rpc_server(ready_rpc_state()).await;
14745 let config = test_config_with_signer_env(
14746 format!("http://{addr}"),
14747 "BLOCKCHAIN_TEST_RECONNECT_CLAIM",
14748 );
14749 let mut client = test_client_from_config(config, test_pool());
14750 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14751 replace_exec_event_sender(sender);
14752 client.start().unwrap();
14753 unsafe { std::env::set_var("BLOCKCHAIN_TEST_RECONNECT_CLAIM", TEST_PRIVATE_KEY) };
14755 *client.in_flight.lock() = Some(InFlightSlot::Preparing(TransactionPurpose::Swap));
14756 client
14757 .pending_tasks
14758 .spawn(async {})
14759 .expect("stale task spawn");
14760
14761 client.connect().await.unwrap();
14762
14763 assert!(client.in_flight.lock().is_none());
14764 }
14765
14766 #[allow(unsafe_code)] #[tokio::test]
14768 async fn reconnect_after_aborted_submission_releases_preparing_claim() {
14769 let state = ready_rpc_state().with_sleep("eth_getBlockByNumber", Duration::from_secs(30));
14772 let Some((admin_pool, schema, mut client, state, _)) =
14773 swap_client_with_database("execution_reconnect_claim_test", state).await
14774 else {
14775 return;
14776 };
14777 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14778 replace_exec_event_sender(sender);
14779 client.start().unwrap();
14780 unsafe { std::env::set_var("BLOCKCHAIN_TEST_PRIVATE_KEY", TEST_PRIVATE_KEY) };
14782
14783 let order = test_market_sell_order(test_pool().instrument_id);
14784 client.submit_order(submit_order_cmd(&order)).unwrap();
14785 await_recorded_requests(&state, "eth_getBlockByNumber", 1).await;
14786 assert!(client.in_flight.lock().is_some());
14787
14788 client.disconnect().await.unwrap();
14789 let ready_addr = start_mock_rpc_server(ready_rpc_state()).await;
14790 let ready = test_client(format!("http://{ready_addr}"));
14791 client.http_rpc_client = ready.http_rpc_client;
14792 client.verification = ready.verification;
14793 client.connect().await.unwrap();
14794
14795 assert!(client.in_flight.lock().is_none());
14796
14797 drop_execution_schema(&admin_pool, &schema).await;
14798 }
14799
14800 #[tokio::test]
14801 async fn poll_for_receipt_returns_none_after_exhaustion() {
14802 let state = ready_rpc_state().with_response("eth_getTransactionReceipt", RECEIPT_NULL);
14803 let (client, state) = client_with_mock_rpc(state).await;
14804
14805 let receipt = poll_for_receipt(&client.http_rpc_client, &B256::ZERO, 3, Duration::ZERO)
14806 .await
14807 .unwrap();
14808
14809 assert!(receipt.is_none());
14810 let requests = state.recorded_requests();
14811 assert_eq!(requests.len(), 3);
14812 }
14813
14814 #[tokio::test]
14815 async fn poll_for_receipt_returns_last_error_when_every_poll_fails() {
14816 let state =
14817 ready_rpc_state().with_response("eth_getTransactionReceipt", RPC_METHOD_NOT_FOUND);
14818 let (client, state) = client_with_mock_rpc(state).await;
14819
14820 let error = poll_for_receipt(&client.http_rpc_client, &B256::ZERO, 3, Duration::ZERO)
14821 .await
14822 .unwrap_err();
14823
14824 assert_eq!(
14825 error.to_string(),
14826 "eth_getTransactionReceipt RPC error -32601"
14827 );
14828 let requests = state.recorded_requests();
14829 assert_eq!(requests.len(), 3);
14830 }
14831
14832 #[tokio::test]
14833 async fn poll_for_receipt_returns_none_after_pending_then_errors() {
14834 let state = ready_rpc_state().with_response_sequence(
14835 "eth_getTransactionReceipt",
14836 &[RECEIPT_NULL, RPC_METHOD_NOT_FOUND, RPC_METHOD_NOT_FOUND],
14837 );
14838 let (client, state) = client_with_mock_rpc(state).await;
14839
14840 let receipt = poll_for_receipt(&client.http_rpc_client, &B256::ZERO, 3, Duration::ZERO)
14841 .await
14842 .unwrap();
14843
14844 assert!(receipt.is_none());
14845 let requests = state.recorded_requests();
14846 assert_eq!(requests.len(), 3);
14847 }
14848
14849 #[tokio::test]
14850 async fn cancellation_during_persistence_keeps_in_flight_slot() {
14851 let Some((admin_pool, pg_config)) = connect_test_postgres("persistence cancellation").await
14852 else {
14853 return;
14854 };
14855
14856 let schema = format!("execution_persist_cancel_test_{}", std::process::id());
14857 setup_execution_schema(&admin_pool, &schema).await;
14858
14859 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
14860 let db_options = db_options.options([("search_path", schema.clone())]);
14861 let database = connect_test_database(db_options).await.unwrap();
14862
14863 let state = signing_rpc_state();
14864 let addr = start_mock_rpc_server(state.clone()).await;
14865
14866 let mut client = test_client(format!("http://{addr}"));
14867 client.cache.database = Some(database);
14868 client
14870 .cache
14871 .ensure_execution_transaction_schema()
14872 .await
14873 .unwrap();
14874 protect_test_storage(&mut client, &schema).await;
14875 initialize_test_verification_ledger(&client).await;
14876 client.signer = Some(Arc::new(
14877 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
14878 ));
14879 client.core.set_connected();
14880
14881 let advisory_lock = i64::from(std::process::id());
14882
14883 for statement in [
14884 format!(
14885 "CREATE FUNCTION {schema}.block_execution_hash_insert() RETURNS trigger \
14886 LANGUAGE plpgsql AS 'BEGIN PERFORM pg_advisory_xact_lock({advisory_lock}); \
14887 RETURN NEW; END'"
14888 ),
14889 format!(
14890 "CREATE TRIGGER block_execution_hash_insert BEFORE INSERT ON \
14891 {schema}.execution_transaction_hash FOR EACH ROW EXECUTE FUNCTION \
14892 {schema}.block_execution_hash_insert()"
14893 ),
14894 ] {
14895 sqlx::query(sqlx::AssertSqlSafe(statement))
14896 .execute(&admin_pool)
14897 .await
14898 .unwrap();
14899 }
14900 let mut lock_transaction = admin_pool.begin().await.unwrap();
14901 sqlx::query("SELECT pg_advisory_xact_lock($1)")
14902 .bind(advisory_lock)
14903 .execute(&mut *lock_transaction)
14904 .await
14905 .unwrap();
14906
14907 let value = U256::from(1_000_000_000_000_000u64);
14908 let in_flight = Arc::clone(&client.in_flight);
14909 let mut wrap = Box::pin(client.wrap(value));
14910 tokio::time::timeout(Duration::from_secs(2), async {
14911 loop {
14912 tokio::select! {
14913 result = &mut wrap => {
14914 panic!("persistence completed while the table was locked: {result:?}")
14915 }
14916 () = tokio::time::sleep(Duration::from_millis(1)) => {}
14917 }
14918
14919 if matches!(*in_flight.lock(), Some(InFlightSlot::AwaitingFinality(_))) {
14920 break;
14921 }
14922 }
14923 })
14924 .await
14925 .unwrap();
14926 drop(wrap);
14927 lock_transaction.rollback().await.unwrap();
14928
14929 let slot = *client.in_flight.lock();
14930 let second_error = client
14931 .wrap(U256::from(2_000_000_000_000_000u64))
14932 .await
14933 .unwrap_err();
14934 let broadcasts = state
14935 .recorded_requests()
14936 .into_iter()
14937 .filter(|request| request["method"] == "eth_sendRawTransaction")
14938 .count();
14939 let status: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
14940 "SELECT status FROM {schema}.execution_intent"
14941 )))
14942 .fetch_one(&admin_pool)
14943 .await
14944 .unwrap();
14945
14946 assert!(matches!(slot, Some(InFlightSlot::AwaitingFinality(_))));
14947 assert_eq!(status, "prepared");
14948 assert!(
14949 second_error.to_string().contains("awaiting finality"),
14950 "was: {second_error}"
14951 );
14952 assert_eq!(broadcasts, 0);
14953
14954 drop_execution_schema(&admin_pool, &schema).await;
14955 }
14956
14957 #[tokio::test]
14958 async fn reservation_failure_releases_preparing_slot() {
14959 let state = signing_rpc_state();
14960 let Some((admin_pool, schema, mut client, state)) =
14961 execution_client_with_database("execution_reservation_fail_test", state).await
14962 else {
14963 return;
14964 };
14965 sqlx::query(sqlx::AssertSqlSafe(format!(
14966 "DROP TABLE {schema}.execution_intent CASCADE"
14967 )))
14968 .execute(&admin_pool)
14969 .await
14970 .unwrap();
14971
14972 let error = client
14973 .wrap(U256::from(1_000_000_000_000_000u64))
14974 .await
14975 .unwrap_err();
14976 let retry_error = client
14977 .wrap(U256::from(2_000_000_000_000_000u64))
14978 .await
14979 .unwrap_err();
14980 let broadcasts = state
14981 .recorded_requests()
14982 .into_iter()
14983 .filter(|request| request["method"] == "eth_sendRawTransaction")
14984 .count();
14985
14986 assert_eq!(
14987 error.to_string(),
14988 "Execution intent reservation failed before commit"
14989 );
14990 assert!(reservation_failure_proven_not_committed(&error));
14991 assert_eq!(
14992 retry_error.to_string(),
14993 "Execution intent reservation failed before commit"
14994 );
14995 assert_eq!(broadcasts, 0);
14996 assert!(client.in_flight.lock().is_none());
14997
14998 drop_execution_schema(&admin_pool, &schema).await;
14999 }
15000
15001 #[tokio::test]
15002 async fn reservation_commit_failure_keeps_preparing_slot() {
15003 let state = signing_rpc_state();
15004 let Some((admin_pool, schema, mut client, state)) =
15005 execution_client_with_database("execution_reservation_commit_fail_test", state).await
15006 else {
15007 return;
15008 };
15009 install_reservation_commit_rejection(&admin_pool, &schema).await;
15010
15011 let error = client
15012 .wrap(U256::from(1_000_000_000_000_000u64))
15013 .await
15014 .unwrap_err();
15015 let slot = *client.in_flight.lock();
15016 let second_error = client
15017 .wrap(U256::from(2_000_000_000_000_000u64))
15018 .await
15019 .unwrap_err();
15020 let requests = state.recorded_requests();
15021 let intent_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
15022 "SELECT COUNT(*) FROM {schema}.execution_intent"
15023 )))
15024 .fetch_one(&admin_pool)
15025 .await
15026 .unwrap();
15027 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
15028 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
15029 )))
15030 .fetch_one(&admin_pool)
15031 .await
15032 .unwrap();
15033
15034 assert_eq!(
15035 error.to_string(),
15036 "Execution intent reservation commit outcome is unknown; reconciliation is required"
15037 );
15038 assert!(!reservation_failure_proven_not_committed(&error));
15039 assert!(matches!(
15040 slot,
15041 Some(InFlightSlot::Preparing(TransactionPurpose::Wrap))
15042 ));
15043 assert!(
15044 second_error.to_string().contains("being prepared"),
15045 "was: {second_error}"
15046 );
15047 assert_eq!(intent_count, 0);
15048 assert_eq!(signed_count, 0);
15049 assert!(
15050 requests
15051 .iter()
15052 .all(|request| request["method"] != "eth_getTransactionCount")
15053 );
15054 assert!(
15055 requests
15056 .iter()
15057 .all(|request| request["method"] != "eth_sendRawTransaction")
15058 );
15059
15060 drop_execution_schema(&admin_pool, &schema).await;
15061 }
15062
15063 #[tokio::test]
15064 async fn persistence_failure_keeps_unbroadcast_slot() {
15065 let state = signing_rpc_state();
15066 let Some((admin_pool, schema, mut client, state)) =
15067 execution_client_with_database("execution_persist_fail_test", state).await
15068 else {
15069 return;
15070 };
15071 sqlx::query(sqlx::AssertSqlSafe(format!(
15072 "ALTER TABLE {schema}.execution_transaction_hash \
15073 ADD CONSTRAINT execution_hash_reject CHECK (FALSE)"
15074 )))
15075 .execute(&admin_pool)
15076 .await
15077 .unwrap();
15078
15079 let value = U256::from(1_000_000_000_000_000u64);
15080 let error = client.wrap(value).await.unwrap_err();
15081 let in_flight = awaiting_in_flight(&client);
15082 let second_error = client
15083 .wrap(U256::from(2_000_000_000_000_000u64))
15084 .await
15085 .unwrap_err();
15086 let broadcasts = state
15087 .recorded_requests()
15088 .into_iter()
15089 .filter(|request| request["method"] == "eth_sendRawTransaction")
15090 .count();
15091 let expected_hash = expected_wrap_tx_hash(value).await;
15092
15093 let error_message = error.to_string();
15094 assert!(error_message.starts_with(&format!(
15095 "Failed to persist transaction {expected_hash}: Failed to persist signed transaction"
15096 )), "was: {error_message}");
15097 assert!(
15098 error_message.ends_with("the in-flight slot stays occupied"),
15099 "was: {error_message}"
15100 );
15101 assert_eq!(in_flight.nonce, 7);
15102 assert_eq!(in_flight.tx_hash, expected_hash);
15103 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15104 assert!(
15105 second_error.to_string().contains("still awaiting finality"),
15106 "was: {second_error}"
15107 );
15108 assert_eq!(broadcasts, 0);
15109
15110 drop_execution_schema(&admin_pool, &schema).await;
15111 }
15112
15113 #[tokio::test]
15114 async fn cancellation_after_dispatch_keeps_record_and_in_flight_slot() {
15115 let state = signing_rpc_state().with_sleep(
15116 "eth_sendRawTransaction",
15117 Duration::from_secs(EXECUTION_RPC_TIMEOUT_SECS + 2),
15118 );
15119 let Some((admin_pool, schema, mut client, state)) =
15120 execution_client_with_database("execution_cancel_test", state).await
15121 else {
15122 return;
15123 };
15124
15125 let mut wrap = Box::pin(client.wrap(U256::from(1_000_000_000_000_000u64)));
15126 tokio::select! {
15127 result = &mut wrap => panic!("broadcast completed before cancellation: {result:?}"),
15128 () = await_recorded_requests(&state, "eth_sendRawTransaction", 1) => {}
15129 }
15130 drop(wrap);
15131
15132 let in_flight = awaiting_in_flight(&client);
15133 let error = client
15134 .wrap(U256::from(2_000_000_000_000_000u64))
15135 .await
15136 .unwrap_err();
15137 let record = client
15138 .cache
15139 .get_execution_transaction(42161, &in_flight.tx_hash.to_string())
15140 .await
15141 .unwrap()
15142 .unwrap();
15143 let broadcasts = state
15144 .recorded_requests()
15145 .into_iter()
15146 .filter(|request| request["method"] == "eth_sendRawTransaction")
15147 .count();
15148
15149 assert!(
15150 error.to_string().contains("still awaiting finality"),
15151 "was: {error}"
15152 );
15153 assert_eq!(record.nonce, 7);
15154 assert_eq!(record.purpose, "wrap");
15155 assert_eq!(record.status, "broadcast");
15156 assert_eq!(broadcasts, 1);
15157
15158 drop_execution_schema(&admin_pool, &schema).await;
15159 }
15160
15161 #[tokio::test]
15162 async fn cancellation_during_receipt_polling_keeps_record_and_in_flight_slot() {
15163 let state = signing_rpc_state()
15164 .with_send_raw_transaction_echo()
15165 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS)
15166 .with_sleep(
15167 "eth_getTransactionReceipt",
15168 Duration::from_secs(EXECUTION_RPC_TIMEOUT_SECS + 2),
15169 );
15170 let Some((admin_pool, schema, mut client, state)) =
15171 execution_client_with_database("execution_receipt_cancel_test", state).await
15172 else {
15173 return;
15174 };
15175
15176 let mut wrap = Box::pin(client.wrap(U256::from(1_000_000_000_000_000u64)));
15177 tokio::select! {
15178 result = &mut wrap => panic!("receipt polling completed before cancellation: {result:?}"),
15179 () = await_recorded_requests(&state, "eth_getTransactionReceipt", 3) => {}
15180 }
15181 drop(wrap);
15182
15183 let in_flight = awaiting_in_flight(&client);
15184 let second_error = client
15185 .wrap(U256::from(2_000_000_000_000_000u64))
15186 .await
15187 .unwrap_err();
15188 let record = client
15189 .cache
15190 .get_execution_transaction(42161, &in_flight.tx_hash.to_string())
15191 .await
15192 .unwrap()
15193 .unwrap();
15194 let requests = state.recorded_requests();
15195
15196 assert_eq!(in_flight.nonce, 7);
15197 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15198 assert!(
15199 second_error.to_string().contains("still awaiting finality"),
15200 "was: {second_error}"
15201 );
15202 assert_eq!(record.nonce, 7);
15203 assert_eq!(record.transaction_hash, in_flight.tx_hash.to_string());
15204 assert_eq!(record.purpose, "wrap");
15205 assert_eq!(record.status, "broadcast");
15206 assert_eq!(
15207 requests
15208 .iter()
15209 .filter(|request| request["method"] == "eth_sendRawTransaction")
15210 .count(),
15211 1
15212 );
15213 assert_eq!(
15214 requests
15215 .iter()
15216 .filter(|request| request["method"] == "eth_getTransactionReceipt")
15217 .count(),
15218 3
15219 );
15220
15221 drop_execution_schema(&admin_pool, &schema).await;
15222 }
15223
15224 #[tokio::test]
15225 async fn rejected_broadcast_stays_dropped_and_occupied() {
15226 let state = signing_rpc_state()
15227 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_REJECTED)
15228 .with_response("eth_getTransactionReceipt", RECEIPT_NULL);
15229 let Some((admin_pool, schema, mut client, state)) =
15230 execution_client_with_database("execution_rejected_test", state).await
15231 else {
15232 return;
15233 };
15234
15235 let error = client
15236 .wrap(U256::from(1_000_000_000_000_000u64))
15237 .await
15238 .unwrap_err();
15239 let (purpose, status): (String, String) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
15240 "SELECT purpose, status FROM {schema}.execution_intent"
15241 )))
15242 .fetch_one(&admin_pool)
15243 .await
15244 .unwrap();
15245 let broadcasts = state
15246 .recorded_requests()
15247 .into_iter()
15248 .filter(|request| request["method"] == "eth_sendRawTransaction")
15249 .count();
15250
15251 assert!(
15252 error.to_string().contains("Timed out awaiting finality"),
15253 "was: {error}"
15254 );
15255 assert!(client.in_flight.lock().is_some());
15256 assert_eq!(purpose, "wrap");
15257 assert_eq!(status, "dropped");
15258 assert_eq!(broadcasts, 1);
15259
15260 drop_execution_schema(&admin_pool, &schema).await;
15261 }
15262
15263 #[tokio::test]
15264 async fn legacy_table_loss_does_not_release_rejected_broadcast() {
15265 let state = signing_rpc_state()
15266 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_REJECTED)
15267 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
15268 .with_sleep("eth_sendRawTransaction", Duration::from_secs(1));
15269 let Some((admin_pool, schema, mut client, state)) =
15270 execution_client_with_database("execution_rejected_update_test", state).await
15271 else {
15272 return;
15273 };
15274
15275 let mut wrap = Box::pin(client.wrap(U256::from(1_000_000_000_000_000u64)));
15276 tokio::select! {
15277 result = &mut wrap => panic!("broadcast completed before database failure: {result:?}"),
15278 () = await_recorded_requests(&state, "eth_sendRawTransaction", 1) => {}
15279 }
15280 sqlx::query(sqlx::AssertSqlSafe(format!(
15281 "DROP TABLE {schema}.execution_transaction"
15282 )))
15283 .execute(&admin_pool)
15284 .await
15285 .unwrap();
15286
15287 let error = wrap.await.unwrap_err();
15288
15289 assert!(
15290 error.to_string().contains("Timed out awaiting finality"),
15291 "was: {error}"
15292 );
15293 let in_flight = awaiting_in_flight(&client);
15294 assert_eq!(in_flight.nonce, 7);
15295 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15296
15297 drop_execution_schema(&admin_pool, &schema).await;
15298 }
15299
15300 #[tokio::test]
15301 async fn finalized_receipt_keeps_slot_when_status_update_fails() {
15302 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000u64)).await;
15303 let block = finalized_wrap_block(expected_hash);
15304 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
15305 let state = with_finalized_identity(
15306 signing_rpc_state()
15307 .with_send_raw_transaction_echo()
15308 .with_response("eth_getTransactionReceipt", &receipt)
15309 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
15310 .with_call_response_sequence(
15311 BALANCE_OF_SELECTOR,
15312 &[
15313 CALL_BALANCE,
15314 CALL_BALANCE,
15315 CALL_BALANCE,
15316 CALL_BALANCE,
15317 CALL_BALANCE,
15318 CALL_BALANCE,
15319 CALL_BALANCE_AFTER_WRAP,
15320 CALL_BALANCE_AFTER_WRAP,
15321 CALL_BALANCE_AFTER_WRAP,
15322 ],
15323 ),
15324 &block,
15325 &receipt,
15326 );
15327 let Some((admin_pool, schema, mut client, state)) =
15328 execution_client_with_database("execution_finalized_update_test", state).await
15329 else {
15330 return;
15331 };
15332
15333 for statement in [
15334 format!(
15335 "CREATE FUNCTION {schema}.reject_finalized_status() RETURNS trigger \
15336 LANGUAGE plpgsql AS 'BEGIN IF NEW.status = ''finalized'' THEN \
15337 RAISE EXCEPTION ''test finalized status rejection''; END IF; \
15338 RETURN NEW; END'"
15339 ),
15340 format!(
15341 "CREATE TRIGGER reject_finalized_status BEFORE UPDATE ON \
15342 {schema}.execution_transaction_hash FOR EACH ROW \
15343 EXECUTE FUNCTION {schema}.reject_finalized_status()"
15344 ),
15345 ] {
15346 sqlx::query(sqlx::AssertSqlSafe(statement))
15347 .execute(&admin_pool)
15348 .await
15349 .unwrap();
15350 }
15351
15352 let error = client
15353 .wrap(U256::from(1_000_000_000_000_000u64))
15354 .await
15355 .unwrap_err();
15356 let in_flight = awaiting_in_flight(&client);
15357 let requests = state.recorded_requests();
15358
15359 assert!(
15360 error
15361 .to_string()
15362 .contains("test finalized status rejection"),
15363 "was: {error}"
15364 );
15365 assert_eq!(in_flight.nonce, 7);
15366 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15367 assert_eq!(
15368 requests
15369 .iter()
15370 .filter(|request| request["method"] == "eth_sendRawTransaction")
15371 .count(),
15372 1
15373 );
15374 assert_eq!(
15375 requests
15376 .iter()
15377 .filter(|request| request["method"] == "eth_getTransactionReceipt")
15378 .count(),
15379 3
15380 );
15381
15382 drop_execution_schema(&admin_pool, &schema).await;
15383 }
15384
15385 #[tokio::test]
15386 async fn broadcast_timeout_marks_dropped_and_keeps_ownership() {
15387 let state = signing_rpc_state()
15388 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
15389 .with_sleep(
15390 "eth_sendRawTransaction",
15391 Duration::from_secs(EXECUTION_RPC_TIMEOUT_SECS + 2),
15392 );
15393 let Some((admin_pool, schema, mut client, _)) =
15394 execution_client_with_database("execution_timeout_test", state).await
15395 else {
15396 return;
15397 };
15398
15399 let error = client
15400 .wrap(U256::from(1_000_000_000_000_000u64))
15401 .await
15402 .unwrap_err();
15403
15404 assert!(
15405 error.to_string().contains("Timed out awaiting finality"),
15406 "was: {error}"
15407 );
15408 let in_flight = awaiting_in_flight(&client);
15409 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15410 assert_eq!(in_flight.nonce, 7);
15411
15412 let record = client
15414 .cache
15415 .get_execution_transaction(42161, &in_flight.tx_hash.to_string())
15416 .await
15417 .unwrap()
15418 .unwrap();
15419 assert_eq!(record.status, "dropped");
15420
15421 drop_execution_schema(&admin_pool, &schema).await;
15422 }
15423
15424 #[tokio::test]
15425 async fn restart_reconciles_dropped_transaction_without_rebroadcast() {
15426 let initial_state = broadcast_rpc_state()
15427 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
15428 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
15429 let Some((admin_pool, schema, mut first_client, _)) =
15430 execution_client_with_database("execution_restart_test", initial_state).await
15431 else {
15432 return;
15433 };
15434 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
15435 let error = first_client
15436 .wrap(U256::from(1_000_000_000_000_000_u64))
15437 .await
15438 .unwrap_err();
15439 assert!(
15440 error.to_string().contains("Timed out awaiting finality"),
15441 "was: {error}"
15442 );
15443 let database = first_client.cache.database.as_ref().unwrap().clone();
15444 let payload_keys = first_client.payload_keys.clone();
15445 drop(first_client);
15446
15447 let block = finalized_wrap_block(expected_hash);
15448 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
15449 let restart_state = with_finalized_identity(
15450 execution_rpc_state()
15451 .with_response("eth_getTransactionReceipt", &receipt)
15452 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
15453 .with_response_sequence(
15454 "eth_call",
15455 &[
15456 CALL_BALANCE,
15457 CALL_BALANCE,
15458 CALL_BALANCE,
15459 CALL_BALANCE_AFTER_WRAP,
15460 CALL_BALANCE_AFTER_WRAP,
15461 CALL_BALANCE_AFTER_WRAP,
15462 ],
15463 ),
15464 &block,
15465 &receipt,
15466 );
15467 let addr = start_mock_rpc_server(restart_state.clone()).await;
15468 let mut restarted = test_client(format!("http://{addr}"));
15469 restarted.cache.database = Some(database);
15470 restarted.payload_keys = payload_keys;
15471 restarted.signer = Some(Arc::new(
15472 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
15473 ));
15474
15475 restarted.reconcile_unresolved_execution().await.unwrap();
15476 restarted.reconcile_unresolved_execution().await.unwrap();
15477
15478 let record = restarted
15479 .cache
15480 .get_execution_transaction(42161, &expected_hash.to_string())
15481 .await
15482 .unwrap()
15483 .unwrap();
15484 let requests = restart_state.recorded_requests();
15485 assert_eq!(record.status, "finalized");
15486 assert!(restarted.in_flight.lock().is_none());
15487 assert_eq!(
15488 requests
15489 .iter()
15490 .filter(|request| request["method"] == "eth_getTransactionReceipt")
15491 .count(),
15492 6
15493 );
15494 assert_eq!(
15495 requests
15496 .iter()
15497 .filter(|request| {
15498 request["method"] == "eth_call"
15499 && request["params"][0]["data"]
15500 .as_str()
15501 .is_some_and(|data| data.starts_with(BALANCE_OF_SELECTOR))
15502 })
15503 .count(),
15504 6
15505 );
15506 assert_eq!(
15507 requests
15508 .iter()
15509 .filter(|request| request["method"] == "eth_sendRawTransaction")
15510 .count(),
15511 0
15512 );
15513
15514 drop_execution_schema(&admin_pool, &schema).await;
15515 }
15516
15517 fn migration_test_intent(id: i64) -> ExecutionIntentRow {
15518 ExecutionIntentRow {
15519 id,
15520 schema_version: crate::execution::transaction::EXECUTION_SCHEMA_VERSION,
15521 chain_id: 42_161,
15522 wallet_address: WALLET.to_string(),
15523 nonce: None,
15524 purpose: "wrap".to_string(),
15525 status: "prepared".to_string(),
15526 client_order_id: None,
15527 trader_id: None,
15528 strategy_id: None,
15529 account_id: None,
15530 instrument_id: None,
15531 pool_address: None,
15532 transaction_to: WETH.to_string(),
15533 transaction_input: "0xd0e30db0".to_string(),
15534 transaction_value: "1".to_string(),
15535 amount_in: None,
15536 created_block: FIXTURE_BLOCK,
15537 acknowledgement_emitted: false,
15538 fill_emitted: false,
15539 terminal_emitted: false,
15540 active: true,
15541 }
15542 }
15543
15544 fn migration_nonce_verification() -> Verified<u64> {
15545 Verified {
15546 value: 7,
15547 read: crate::rpc::verification::VerificationRead::TransactionCount,
15548 provider_ids: [
15549 "authoritative".to_string(),
15550 "verifier-a".to_string(),
15551 "verifier-b".to_string(),
15552 ],
15553 normalized_value_digest: keccak256(7_u64.to_be_bytes()),
15554 }
15555 }
15556
15557 fn migration_finalized_header() -> VerifiedBlockHeader {
15558 VerifiedBlockHeader {
15559 number: FIXTURE_BLOCK,
15560 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
15561 parent_hash: B256::from_str(
15562 "0x0000000000000000000000000000000000000000000000000000000000000001",
15563 )
15564 .unwrap(),
15565 timestamp: FIXTURE_BLOCK_TIMESTAMP,
15566 base_fee_per_gas: Some(100_000_000),
15567 }
15568 }
15569
15570 #[tokio::test]
15571 async fn verification_migration_recovers_prepared_unassigned_intent() {
15572 let client = test_client("http://127.0.0.1:1".to_string());
15573 let snapshot = ExecutionVerificationMigrationSnapshot {
15574 intents: vec![migration_test_intent(1)],
15575 hashes: Vec::new(),
15576 };
15577 let finalized = migration_finalized_header();
15578 let migration = client
15579 .build_execution_verification_migration(
15580 snapshot,
15581 finalized,
15582 &[finalized],
15583 &migration_nonce_verification(),
15584 )
15585 .await
15586 .unwrap();
15587
15588 assert_eq!(migration.records.len(), 1);
15589 let record = &migration.records[0];
15590 assert_eq!(record.intent_id, 1);
15591 assert_eq!(record.nonce, None);
15592 assert_eq!(record.transaction_hash, None);
15593 assert!(record.recover_prepared);
15594 assert_eq!(record.decisions.len(), 1);
15595 }
15596
15597 #[tokio::test]
15598 async fn verification_migration_rejects_inconsistent_released_history() {
15599 let client = test_client("http://127.0.0.1:1".to_string());
15600 let finalized = migration_finalized_header();
15601 let nonce_verification = migration_nonce_verification();
15602 let mut missing_marker = migration_test_intent(1);
15603 missing_marker.active = false;
15604 missing_marker.status = "finalized".to_string();
15605 missing_marker.nonce = Some(6);
15606 let error = client
15607 .build_execution_verification_migration(
15608 ExecutionVerificationMigrationSnapshot {
15609 intents: vec![missing_marker],
15610 hashes: Vec::new(),
15611 },
15612 finalized,
15613 &[finalized],
15614 &nonce_verification,
15615 )
15616 .await
15617 .err()
15618 .unwrap();
15619 assert!(
15620 error.to_string().contains("has no durable event marker"),
15621 "was: {error}"
15622 );
15623
15624 let mut first = migration_test_intent(1);
15625 first.active = false;
15626 first.status = "recoverable".to_string();
15627 first.nonce = Some(6);
15628 let mut second = first.clone();
15629 second.id = 2;
15630 let error = client
15631 .build_execution_verification_migration(
15632 ExecutionVerificationMigrationSnapshot {
15633 intents: vec![first, second],
15634 hashes: Vec::new(),
15635 },
15636 finalized,
15637 &[finalized],
15638 &nonce_verification,
15639 )
15640 .await
15641 .err()
15642 .unwrap();
15643 assert!(
15644 error
15645 .to_string()
15646 .contains("duplicate signer nonce ownership"),
15647 "was: {error}"
15648 );
15649 }
15650
15651 #[tokio::test]
15652 async fn verification_migration_reconstructs_consumed_active_intent() {
15653 let expected_hash = expected_wrap_tx_hash(U256::from(1u64)).await;
15654 let mut block_value: serde_json::Value =
15655 serde_json::from_str(&finalized_wrap_block(expected_hash)).unwrap();
15656 block_value["result"]["transactions"][0]["value"] = serde_json::json!("0x1");
15657 let block = block_value.to_string();
15658 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
15659 let state = with_finalized_identity(
15660 execution_rpc_state()
15661 .with_response("eth_getTransactionCount", TRANSACTION_COUNT_NEXT)
15662 .with_response("eth_getTransactionReceipt", &receipt)
15663 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block),
15664 &block,
15665 &receipt,
15666 );
15667 let Some((admin_pool, pg_config)) =
15668 connect_test_postgres("verification_migration_reconstructs_consumed_active_intent")
15669 .await
15670 else {
15671 return;
15672 };
15673 let schema = format!(
15674 "verification_migration_reconstructs_consumed_active_intent_{}",
15675 std::process::id()
15676 );
15677 setup_execution_schema(&admin_pool, &schema).await;
15678 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
15679 let database = connect_test_database(options.options([("search_path", schema.clone())]))
15680 .await
15681 .unwrap();
15682 let addr = start_mock_rpc_server(state).await;
15683 let (mut client, _) = swap_client_with_cache(test_config(format!("http://{addr}")));
15684 client.cache.database = Some(database.clone());
15685 client
15686 .cache
15687 .ensure_execution_transaction_schema()
15688 .await
15689 .unwrap();
15690 let (intent, persisted_hash, _) = persist_test_wrap_broadcast(&database, None).await;
15691 protect_test_storage(&mut client, &schema).await;
15692 assert_eq!(persisted_hash, expected_hash);
15693
15694 let snapshot = database
15695 .load_execution_verification_migration_snapshot(42_161, WALLET)
15696 .await
15697 .unwrap();
15698 let nonce_verification = required_verification(
15699 client
15700 .verification
15701 .verify_transaction_count(&Address::from_str(WALLET).unwrap(), FIXTURE_BLOCK + 1)
15702 .await,
15703 "test migration nonce",
15704 )
15705 .unwrap();
15706 let headers = [
15707 VerifiedBlockHeader {
15708 number: FIXTURE_BLOCK,
15709 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
15710 parent_hash: B256::from_str(
15711 "0x0000000000000000000000000000000000000000000000000000000000000001",
15712 )
15713 .unwrap(),
15714 timestamp: FIXTURE_BLOCK_TIMESTAMP,
15715 base_fee_per_gas: Some(100_000_000),
15716 },
15717 VerifiedBlockHeader {
15718 number: FIXTURE_BLOCK + 1,
15719 hash: B256::from([0x22; 32]),
15720 parent_hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
15721 timestamp: FIXTURE_BLOCK_TIMESTAMP + 1,
15722 base_fee_per_gas: Some(100_000_000),
15723 },
15724 ];
15725 let migration = client
15726 .build_execution_verification_migration(
15727 snapshot,
15728 headers[1],
15729 &headers,
15730 &nonce_verification,
15731 )
15732 .await
15733 .unwrap();
15734 let finalized_headers = headers
15735 .iter()
15736 .map(|header| ExecutionVerifiedHeader {
15737 number: header.number,
15738 hash: header.hash.to_string(),
15739 parent_hash: header.parent_hash.to_string(),
15740 timestamp: header.timestamp,
15741 base_fee_per_gas: header.base_fee_per_gas,
15742 })
15743 .collect::<Vec<_>>();
15744 let decisions = [verification_decision(
15745 &nonce_verification,
15746 Some(FIXTURE_BLOCK + 1),
15747 Some(FIXTURE_BLOCK + 1),
15748 )];
15749
15750 initialize_test_verification_migration(
15751 &client,
15752 &finalized_headers,
15753 8,
15754 &decisions,
15755 &migration,
15756 )
15757 .await;
15758
15759 let migrated = database.get_execution_intent(intent.id).await.unwrap();
15760 let resume = database
15761 .load_execution_verification_resume(
15762 42_161,
15763 WALLET,
15764 &client
15765 .config
15766 .verification
15767 .as_ref()
15768 .unwrap()
15769 .manifest_version,
15770 &client.config.verification.as_ref().unwrap().manifest_digest,
15771 )
15772 .await
15773 .unwrap()
15774 .unwrap();
15775 let evidence: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
15776 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
15777 WHERE intent_id = $1 AND decision_class = 'migration'"
15778 )))
15779 .bind(intent.id)
15780 .fetch_one(&admin_pool)
15781 .await
15782 .unwrap();
15783
15784 assert_eq!(migrated.status, "finalized");
15785 assert!(migrated.active);
15786 assert_eq!(resume.next_canonical_nonce, 8);
15787 assert!(evidence >= 5);
15788
15789 drop_execution_schema(&admin_pool, &schema).await;
15790 }
15791
15792 #[tokio::test]
15793 async fn verification_resume_accepts_one_consumed_owned_nonce() {
15794 let Some((admin_pool, schema, client, _)) = execution_client_with_database(
15795 "verification_resume_owned_nonce_test",
15796 ready_rpc_state(),
15797 )
15798 .await
15799 else {
15800 return;
15801 };
15802 let database = client.cache.database.as_ref().unwrap();
15803 persist_test_wrap_broadcast(database, client.payload_keys.as_deref()).await;
15804 let resume = database
15805 .load_execution_verification_resume(
15806 42_161,
15807 WALLET,
15808 &client
15809 .config
15810 .verification
15811 .as_ref()
15812 .unwrap()
15813 .manifest_version,
15814 &client.config.verification.as_ref().unwrap().manifest_digest,
15815 )
15816 .await
15817 .unwrap()
15818 .unwrap();
15819
15820 ensure_test_verification_ledger(&client, &resume.finalized_headers, 7, 8)
15821 .await
15822 .unwrap();
15823
15824 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
15825 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
15826 )))
15827 .fetch_one(&admin_pool)
15828 .await
15829 .unwrap();
15830 assert_eq!(nonce_state, (7, 0));
15831
15832 drop_execution_schema(&admin_pool, &schema).await;
15833 }
15834
15835 #[tokio::test]
15836 async fn verification_resume_rejects_unowned_nonce_advance() {
15837 for (test_name, mutation, observed_canonical_nonce) in [
15838 ("verification_resume_no_active_test", "no_active", 8),
15839 ("verification_resume_excess_test", "unchanged", 9),
15840 ("verification_resume_signed_test", "signed", 8),
15841 ("verification_resume_prepared_test", "prepared", 8),
15842 (
15843 "verification_resume_missing_payload_test",
15844 "missing_payload",
15845 8,
15846 ),
15847 ] {
15848 let Some((admin_pool, schema, client, _)) =
15849 execution_client_with_database(test_name, ready_rpc_state()).await
15850 else {
15851 return;
15852 };
15853 let database = client.cache.database.as_ref().unwrap();
15854 let (intent, _, _) =
15855 persist_test_wrap_broadcast(database, client.payload_keys.as_deref()).await;
15856
15857 match mutation {
15858 "no_active" => {
15859 sqlx::query(sqlx::AssertSqlSafe(format!(
15860 "UPDATE {schema}.execution_intent SET active = FALSE WHERE id = $1"
15861 )))
15862 .bind(intent.id)
15863 .execute(&admin_pool)
15864 .await
15865 .unwrap();
15866 }
15867 "signed" => {
15868 sqlx::query(sqlx::AssertSqlSafe(format!(
15869 "UPDATE {schema}.execution_intent SET status = 'signed' WHERE id = $1"
15870 )))
15871 .bind(intent.id)
15872 .execute(&admin_pool)
15873 .await
15874 .unwrap();
15875 sqlx::query(sqlx::AssertSqlSafe(format!(
15876 "UPDATE {schema}.execution_transaction_hash SET status = 'signed' \
15877 WHERE intent_id = $1"
15878 )))
15879 .bind(intent.id)
15880 .execute(&admin_pool)
15881 .await
15882 .unwrap();
15883 }
15884 "prepared" => {
15885 sqlx::query(sqlx::AssertSqlSafe(format!(
15886 "UPDATE {schema}.execution_intent \
15887 SET nonce = NULL, status = 'prepared' WHERE id = $1"
15888 )))
15889 .bind(intent.id)
15890 .execute(&admin_pool)
15891 .await
15892 .unwrap();
15893 sqlx::query(sqlx::AssertSqlSafe(format!(
15894 "UPDATE {schema}.execution_transaction_hash SET current = FALSE \
15895 WHERE intent_id = $1"
15896 )))
15897 .bind(intent.id)
15898 .execute(&admin_pool)
15899 .await
15900 .unwrap();
15901 }
15902 "missing_payload" => {
15903 sqlx::query(sqlx::AssertSqlSafe(format!(
15904 "UPDATE {schema}.execution_transaction_hash \
15905 SET payload_expected = FALSE, sealed_transaction = NULL \
15906 WHERE intent_id = $1"
15907 )))
15908 .bind(intent.id)
15909 .execute(&admin_pool)
15910 .await
15911 .unwrap();
15912 }
15913 "unchanged" => {}
15914 _ => unreachable!(),
15915 }
15916 let resume = database
15917 .load_execution_verification_resume(
15918 42_161,
15919 WALLET,
15920 &client
15921 .config
15922 .verification
15923 .as_ref()
15924 .unwrap()
15925 .manifest_version,
15926 &client.config.verification.as_ref().unwrap().manifest_digest,
15927 )
15928 .await
15929 .unwrap()
15930 .unwrap();
15931
15932 let error = ensure_test_verification_ledger(
15933 &client,
15934 &resume.finalized_headers,
15935 7,
15936 observed_canonical_nonce,
15937 )
15938 .await
15939 .unwrap_err();
15940
15941 assert!(
15942 error
15943 .to_string()
15944 .contains(if observed_canonical_nonce == 9 {
15945 "outside the owned recovery range"
15946 } else {
15947 "without"
15948 }),
15949 "{mutation}: {error}"
15950 );
15951 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
15952 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
15953 )))
15954 .fetch_one(&admin_pool)
15955 .await
15956 .unwrap();
15957 assert_eq!(nonce_state, (7, 0), "{mutation}");
15958
15959 drop_execution_schema(&admin_pool, &schema).await;
15960 }
15961 }
15962
15963 #[tokio::test]
15964 async fn restart_marks_prepared_intent_recoverable_without_broadcast() {
15965 let Some((admin_pool, schema, client, state)) =
15966 execution_client_with_database("execution_prepared_restart_test", ready_rpc_state())
15967 .await
15968 else {
15969 return;
15970 };
15971 let database = client.cache.database.as_ref().unwrap();
15972 let intent = reserve_test_wrap_intent(database).await;
15973 *client.in_flight.lock() = Some(InFlightSlot::Preparing(TransactionPurpose::Wrap));
15974
15975 client.reconcile_unresolved_execution().await.unwrap();
15976
15977 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
15978 "SELECT status, active FROM {schema}.execution_intent WHERE id = {}",
15979 intent.id
15980 )))
15981 .fetch_one(&admin_pool)
15982 .await
15983 .unwrap();
15984 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
15985 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
15986 )))
15987 .fetch_all(&admin_pool)
15988 .await
15989 .unwrap();
15990
15991 assert_eq!(status, "recoverable");
15992 assert!(!active);
15993 assert_eq!(transitions, ["prepared", "recoverable"]);
15994 assert!(client.in_flight.lock().is_none());
15995 assert!(
15996 state
15997 .recorded_requests()
15998 .iter()
15999 .all(|request| request["method"] != "eth_sendRawTransaction")
16000 );
16001
16002 drop_execution_schema(&admin_pool, &schema).await;
16003 }
16004
16005 #[tokio::test]
16006 async fn protected_restart_authenticates_envelope_before_recovery() {
16007 let initial_state = broadcast_rpc_state()
16008 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16009 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16010 let Some((admin_pool, schema, mut first_client, _)) =
16011 execution_client_with_database("protected_restart_test", initial_state).await
16012 else {
16013 return;
16014 };
16015 let database = first_client.cache.database.as_ref().unwrap().clone();
16016 let value = U256::from(1_000_000_000_000_000_u64);
16017 let expected_hash = expected_wrap_tx_hash(value).await;
16018
16019 let error = first_client.wrap(value).await.unwrap_err();
16020
16021 assert!(error.to_string().contains("Timed out awaiting finality"));
16022 let intent = database
16023 .get_active_execution_intent(42161, WALLET)
16024 .await
16025 .unwrap()
16026 .unwrap();
16027 let payload = database
16028 .get_execution_transaction_hashes(intent.id)
16029 .await
16030 .unwrap()
16031 .pop()
16032 .unwrap();
16033 assert!(payload.raw_transaction.is_none());
16034 assert!(payload.sealed_transaction.is_some());
16035 let keys = first_client.payload_keys.take().unwrap();
16036 drop(first_client);
16037
16038 let block = finalized_wrap_block(expected_hash);
16039 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
16040 let restart_state = with_finalized_identity(
16041 execution_rpc_state()
16042 .with_response("eth_getTransactionReceipt", &receipt)
16043 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
16044 .with_response_sequence(
16045 "eth_call",
16046 &[
16047 CALL_BALANCE,
16048 CALL_BALANCE,
16049 CALL_BALANCE,
16050 CALL_BALANCE_AFTER_WRAP,
16051 CALL_BALANCE_AFTER_WRAP,
16052 CALL_BALANCE_AFTER_WRAP,
16053 ],
16054 ),
16055 &block,
16056 &receipt,
16057 );
16058 let addr = start_mock_rpc_server(restart_state.clone()).await;
16059 let mut restarted = test_client(format!("http://{addr}"));
16060 restarted.cache.database = Some(database);
16061 restarted.payload_keys = Some(keys);
16062 restarted.signer = Some(Arc::new(
16063 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16064 ));
16065
16066 restarted.reconcile_unresolved_execution().await.unwrap();
16067
16068 let record = restarted
16069 .cache
16070 .get_execution_transaction(42161, &expected_hash.to_string())
16071 .await
16072 .unwrap()
16073 .unwrap();
16074 let requests = restart_state.recorded_requests();
16075 assert_eq!(record.status, "finalized");
16076 assert!(restarted.in_flight.lock().is_none());
16077 assert_eq!(
16078 requests
16079 .iter()
16080 .filter(|request| request["method"] == "eth_sendRawTransaction")
16081 .count(),
16082 0
16083 );
16084
16085 drop_execution_schema(&admin_pool, &schema).await;
16086 }
16087
16088 #[tokio::test]
16089 async fn active_intent_reconciliation_waits_for_reservation_fence() {
16090 let Some((admin_pool, schema, client, _)) = execution_client_with_database(
16091 "execution_active_intent_reservation_fence_test",
16092 ready_rpc_state(),
16093 )
16094 .await
16095 else {
16096 return;
16097 };
16098 let database = client.cache.database.as_ref().unwrap().clone();
16099 let lock = PgAdvisoryLock::new(format!(
16100 "nautilus:blockchain:execution:42161:{}",
16101 WALLET.to_ascii_lowercase()
16102 ));
16103 let PgAdvisoryLockKey::BigInt(lock_key) = lock.key() else {
16104 unreachable!("string advisory locks use the 64-bit key space");
16105 };
16106 let mut reservation = admin_pool.begin().await.unwrap();
16107 sqlx::query("SELECT pg_advisory_xact_lock($1)")
16108 .bind(*lock_key)
16109 .execute(&mut *reservation)
16110 .await
16111 .unwrap();
16112 sqlx::query(sqlx::AssertSqlSafe(format!(
16113 "INSERT INTO {schema}.execution_intent (\
16114 schema_version, chain_id, wallet_address, purpose, status, transaction_to, \
16115 transaction_input, transaction_value, created_block\
16116 ) VALUES (2, $1, $2, 'wrap', 'prepared', $3, '0xd0e30db0', '1', $4)"
16117 )))
16118 .bind(42161_i32)
16119 .bind(WALLET)
16120 .bind(WETH_ADDRESS.to_string())
16121 .bind(i64::try_from(FIXTURE_BLOCK).unwrap())
16122 .execute(&mut *reservation)
16123 .await
16124 .unwrap();
16125
16126 let mut reconciliation = Box::pin(database.get_active_execution_intent(42161, WALLET));
16127 assert!(
16128 tokio::time::timeout(Duration::from_millis(100), &mut reconciliation)
16129 .await
16130 .is_err(),
16131 "reconciliation did not wait for the reservation fence"
16132 );
16133
16134 reservation.commit().await.unwrap();
16135 let intent = tokio::time::timeout(Duration::from_secs(2), reconciliation)
16136 .await
16137 .unwrap()
16138 .unwrap()
16139 .unwrap();
16140
16141 assert_eq!(intent.chain_id, 42161);
16142 assert_eq!(intent.wallet_address, WALLET);
16143 assert_eq!(intent.purpose, "wrap");
16144 assert_eq!(intent.status, "prepared");
16145 assert!(intent.active);
16146
16147 drop_execution_schema(&admin_pool, &schema).await;
16148 }
16149
16150 #[tokio::test]
16151 async fn restart_keeps_unbroadcast_signed_intent_reserved() {
16152 let Some((admin_pool, schema, client, state)) =
16153 execution_client_with_database("execution_signed_restart_test", ready_rpc_state())
16154 .await
16155 else {
16156 return;
16157 };
16158 let database = client.cache.database.as_ref().unwrap();
16159 let intent = reserve_test_wrap_intent(database).await;
16160 let transaction = build_eip1559_transaction(
16161 42161,
16162 7,
16163 78_000,
16164 130_000_000,
16165 10_000_000,
16166 WETH_ADDRESS,
16167 U256::from(1u64),
16168 Bytes::from(nautilus_core::hex::decode("d0e30db0").unwrap()),
16169 );
16170 let (tx_hash, raw_transaction) = sign_eip1559_transaction(
16171 transaction,
16172 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16173 )
16174 .await
16175 .unwrap();
16176 database
16177 .assign_execution_intent_nonce(intent.id, 7)
16178 .await
16179 .unwrap();
16180 let intent = database.get_execution_intent(intent.id).await.unwrap();
16181 persist_test_payload(
16182 database,
16183 client.payload_keys.as_deref(),
16184 &intent,
16185 tx_hash,
16186 &raw_transaction,
16187 )
16188 .await;
16189
16190 let error = client.reconcile_unresolved_execution().await.unwrap_err();
16191
16192 assert!(
16193 error
16194 .to_string()
16195 .contains("was not authorized for broadcast"),
16196 "was: {error}"
16197 );
16198 let recovery = recovering_in_flight(&client);
16199 assert_eq!(recovery.intent_id, intent.id);
16200 assert_eq!(recovery.nonce, 7);
16201 assert_eq!(recovery.purpose, TransactionPurpose::Wrap);
16202 assert_eq!(
16203 execution_intent_markers(&admin_pool, &schema).await,
16204 vec![("wrap".into(), "signed".into(), false, true)]
16205 );
16206 assert!(
16207 state
16208 .recorded_requests()
16209 .iter()
16210 .all(|request| request["method"] != "eth_sendRawTransaction")
16211 );
16212
16213 drop_execution_schema(&admin_pool, &schema).await;
16214 }
16215
16216 #[tokio::test]
16217 async fn restart_quarantines_legacy_recoverable_signed_intent() {
16218 let Some((admin_pool, schema, client, state)) = execution_client_with_database(
16219 "execution_legacy_recoverable_restart_test",
16220 ready_rpc_state(),
16221 )
16222 .await
16223 else {
16224 return;
16225 };
16226 let database = client.cache.database.as_ref().unwrap();
16227 let intent = reserve_test_wrap_intent(database).await;
16228 let tx_hash = B256::from([0x55; 32]);
16229 database
16230 .assign_execution_intent_nonce(intent.id, 7)
16231 .await
16232 .unwrap();
16233 let intent = database.get_execution_intent(intent.id).await.unwrap();
16234 persist_test_payload(
16235 database,
16236 client.payload_keys.as_deref(),
16237 &intent,
16238 tx_hash,
16239 &[0x01, 0x02, 0x03],
16240 )
16241 .await;
16242 sqlx::query(sqlx::AssertSqlSafe(format!(
16243 "UPDATE {schema}.execution_intent SET status = 'recoverable', active = FALSE WHERE id = {}",
16244 intent.id
16245 )))
16246 .execute(&admin_pool)
16247 .await
16248 .unwrap();
16249
16250 let error = client.reconcile_unresolved_execution().await.unwrap_err();
16251
16252 assert!(
16253 error
16254 .to_string()
16255 .contains("retains signed transaction bytes"),
16256 "was: {error}"
16257 );
16258 assert!(client.in_flight.lock().is_none());
16259 assert!(
16260 state
16261 .recorded_requests()
16262 .iter()
16263 .all(|request| request["method"] != "eth_sendRawTransaction")
16264 );
16265
16266 drop_execution_schema(&admin_pool, &schema).await;
16267 }
16268
16269 #[rstest]
16270 #[case::current("execution_invalid_signed_restart_test", false, "broadcast")]
16271 #[case::historical("execution_invalid_historical_restart_test", true, "replaced")]
16272 #[tokio::test]
16273 async fn restart_rejects_invalid_signed_bytes_before_recovery_effects(
16274 #[case] test_name: &str,
16275 #[case] historical: bool,
16276 #[case] expected_status: &str,
16277 ) {
16278 let Some((admin_pool, schema, first_client, state, _)) =
16279 swap_client_with_database(test_name, ready_rpc_state()).await
16280 else {
16281 return;
16282 };
16283 let database = first_client.cache.database.as_ref().unwrap().clone();
16284 let (intent, _) =
16285 persist_invalid_test_swap(&database, first_client.payload_keys.as_deref()).await;
16286
16287 if historical {
16288 sqlx::query(sqlx::AssertSqlSafe(format!(
16289 "UPDATE {schema}.execution_transaction_hash \
16290 SET current = FALSE, status = 'replaced' WHERE intent_id = $1"
16291 )))
16292 .bind(intent.id)
16293 .execute(&admin_pool)
16294 .await
16295 .unwrap();
16296 sqlx::query(sqlx::AssertSqlSafe(format!(
16297 "INSERT INTO {schema}.execution_transaction_hash (\
16298 intent_id, chain_id, transaction_hash, payload_expected, status, current\
16299 ) VALUES ($1, 42161, $2, FALSE, 'replaced', TRUE)"
16300 )))
16301 .bind(intent.id)
16302 .bind(B256::from([0x44; 32]).to_string())
16303 .execute(&admin_pool)
16304 .await
16305 .unwrap();
16306 sqlx::query(sqlx::AssertSqlSafe(format!(
16307 "UPDATE {schema}.execution_intent SET status = 'replaced' WHERE id = $1"
16308 )))
16309 .bind(intent.id)
16310 .execute(&admin_pool)
16311 .await
16312 .unwrap();
16313 }
16314 let transitions_before: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16315 "SELECT COUNT(*) FROM {schema}.execution_transaction_transition"
16316 )))
16317 .fetch_one(&admin_pool)
16318 .await
16319 .unwrap();
16320 let config = first_client.config.clone();
16321 let payload_keys = first_client.payload_keys.clone();
16322 drop(first_client);
16323
16324 let (mut restarted, _) = swap_client_with_cache(config);
16325 restarted.cache.database = Some(database);
16326 restarted.payload_keys = payload_keys;
16327 restarted.signer = Some(Arc::new(
16328 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16329 ));
16330 let mut receiver = start_with_events(&mut restarted);
16331
16332 let error = restarted
16333 .reconcile_unresolved_execution()
16334 .await
16335 .unwrap_err();
16336
16337 let recovery = recovering_in_flight(&restarted);
16338 let (status, acknowledgement_emitted, active): (String, bool, bool) =
16339 sqlx::query_as(sqlx::AssertSqlSafe(format!(
16340 "SELECT status, acknowledgement_emitted, active FROM {schema}.execution_intent"
16341 )))
16342 .fetch_one(&admin_pool)
16343 .await
16344 .unwrap();
16345 let transitions_after: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16346 "SELECT COUNT(*) FROM {schema}.execution_transaction_transition"
16347 )))
16348 .fetch_one(&admin_pool)
16349 .await
16350 .unwrap();
16351
16352 assert!(
16353 error
16354 .to_string()
16355 .contains("is not a complete EIP-2718 envelope"),
16356 "was: {error}"
16357 );
16358 assert_eq!(recovery.intent_id, intent.id);
16359 assert_eq!(recovery.nonce, 7);
16360 assert_eq!(recovery.purpose, TransactionPurpose::Swap);
16361 assert_eq!(status, expected_status);
16362 assert!(!acknowledgement_emitted);
16363 assert!(active);
16364 assert_eq!(transitions_after, transitions_before);
16365 assert!(collect_order_events(&mut receiver).is_empty());
16366 assert!(
16367 state
16368 .recorded_requests()
16369 .iter()
16370 .all(|request| request["method"] != "eth_sendRawTransaction")
16371 );
16372
16373 drop_execution_schema(&admin_pool, &schema).await;
16374 }
16375
16376 #[tokio::test]
16377 async fn restart_rebroadcasts_only_durably_authorized_bytes() {
16378 let Some((admin_pool, schema, first_client, _)) =
16379 execution_client_with_database("execution_broadcast_restart_test", ready_rpc_state())
16380 .await
16381 else {
16382 return;
16383 };
16384 let database = first_client.cache.database.as_ref().unwrap().clone();
16385 let (_, _, raw_tx) =
16386 persist_test_wrap_broadcast(&database, first_client.payload_keys.as_deref()).await;
16387 let payload_keys = first_client.payload_keys.clone();
16388 drop(first_client);
16389
16390 let restart_state = execution_rpc_state()
16391 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
16392 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16393 .with_response("eth_call", CALL_EMPTY)
16394 .with_send_raw_transaction_echo();
16395 let addr = start_mock_rpc_server(restart_state.clone()).await;
16396 let mut restarted = test_client(format!("http://{addr}"));
16397 restarted.cache.database = Some(database);
16398 restarted.payload_keys = payload_keys;
16399 restarted.signer = Some(Arc::new(
16400 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16401 ));
16402
16403 restarted.reconcile_unresolved_execution().await.unwrap();
16404
16405 let broadcasts = restart_state
16406 .recorded_requests()
16407 .into_iter()
16408 .filter(|request| request["method"] == "eth_sendRawTransaction")
16409 .collect::<Vec<_>>();
16410 assert_eq!(broadcasts.len(), 1);
16411 assert_eq!(broadcasts[0]["params"][0], hex::encode_prefixed(&raw_tx));
16412 assert_eq!(
16413 execution_intent_markers(&admin_pool, &schema).await,
16414 vec![("wrap".into(), "dropped".into(), false, true)]
16415 );
16416
16417 drop_execution_schema(&admin_pool, &schema).await;
16418 }
16419
16420 #[tokio::test]
16421 async fn restart_suppresses_rebroadcast_when_canonical_nonce_advanced() {
16422 let Some((admin_pool, schema, first_client, _)) = execution_client_with_database(
16423 "execution_rebroadcast_nonce_advanced_test",
16424 ready_rpc_state(),
16425 )
16426 .await
16427 else {
16428 return;
16429 };
16430 let database = first_client.cache.database.as_ref().unwrap().clone();
16431 let (intent, _, _) =
16432 persist_test_wrap_broadcast(&database, first_client.payload_keys.as_deref()).await;
16433 let payload_keys = first_client.payload_keys.clone();
16434 drop(first_client);
16435
16436 let mut empty_block: serde_json::Value =
16437 serde_json::from_str(&replacement_head_block(B256::from([0x44; 32]))).unwrap();
16438 empty_block["result"]["transactions"] = serde_json::json!([]);
16439 let empty_block = empty_block.to_string();
16440 let restart_state = execution_rpc_state()
16441 .with_response("eth_getTransactionCount", TRANSACTION_COUNT_NEXT)
16442 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16443 .with_response("eth_call", CALL_EMPTY)
16444 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d40", &empty_block)
16445 .with_send_raw_transaction_echo();
16446 let addr = start_mock_rpc_server(restart_state.clone()).await;
16447 let mut restarted = test_client(format!("http://{addr}"));
16448 restarted.cache.database = Some(database);
16449 restarted.payload_keys = payload_keys;
16450 restarted.signer = Some(Arc::new(
16451 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16452 ));
16453
16454 let error = restarted
16455 .reconcile_unresolved_execution()
16456 .await
16457 .unwrap_err();
16458
16459 let requests = restart_state.recorded_requests();
16460 assert!(
16461 requests
16462 .iter()
16463 .all(|request| request["method"] != "eth_sendRawTransaction")
16464 );
16465 assert!(requests.iter().all(|request| {
16466 request["method"] != "eth_call" || request["params"][0]["data"] != "0xd0e30db0"
16467 }));
16468 let decision_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16469 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16470 WHERE intent_id = $1 AND decision_class = 'rebroadcast'"
16471 )))
16472 .bind(intent.id)
16473 .fetch_one(&admin_pool)
16474 .await
16475 .unwrap();
16476 assert!(
16477 error
16478 .to_string()
16479 .contains("without an authenticated signer transaction"),
16480 "was: {error}"
16481 );
16482 assert_eq!(decision_count, 6);
16483 let replacement_decision_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16484 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16485 WHERE intent_id = $1 AND decision_class = 'replacement_scan'"
16486 )))
16487 .bind(intent.id)
16488 .fetch_one(&admin_pool)
16489 .await
16490 .unwrap();
16491 assert_eq!(replacement_decision_count, 2);
16492 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
16493 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
16494 )))
16495 .fetch_one(&admin_pool)
16496 .await
16497 .unwrap();
16498 assert_eq!(nonce_state, (7, 0));
16499
16500 drop_execution_schema(&admin_pool, &schema).await;
16501 }
16502
16503 #[tokio::test]
16504 async fn restart_suppresses_rebroadcast_when_receipt_exists() {
16505 let Some((admin_pool, schema, first_client, _)) = execution_client_with_database(
16506 "execution_rebroadcast_receipt_present_test",
16507 ready_rpc_state(),
16508 )
16509 .await
16510 else {
16511 return;
16512 };
16513 let database = first_client.cache.database.as_ref().unwrap().clone();
16514 let (intent, _, _) =
16515 persist_test_wrap_broadcast(&database, first_client.payload_keys.as_deref()).await;
16516 let payload_keys = first_client.payload_keys.clone();
16517 drop(first_client);
16518
16519 let restart_state = execution_rpc_state()
16520 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
16521 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS)
16522 .with_response("eth_call", CALL_EMPTY)
16523 .with_send_raw_transaction_echo();
16524 let addr = start_mock_rpc_server(restart_state.clone()).await;
16525 let mut restarted = test_client(format!("http://{addr}"));
16526 restarted.cache.database = Some(database);
16527 restarted.payload_keys = payload_keys;
16528 restarted.signer = Some(Arc::new(
16529 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16530 ));
16531
16532 let error = restarted
16533 .reconcile_unresolved_execution()
16534 .await
16535 .unwrap_err();
16536
16537 assert!(
16538 error
16539 .to_string()
16540 .contains("finalized transaction verification is locally invalid"),
16541 "was: {error}"
16542 );
16543 let requests = restart_state.recorded_requests();
16544 assert!(
16545 requests
16546 .iter()
16547 .all(|request| request["method"] != "eth_sendRawTransaction")
16548 );
16549 assert!(requests.iter().all(|request| {
16550 request["method"] != "eth_call" || request["params"][0]["data"] != "0xd0e30db0"
16551 }));
16552 let decision_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16553 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16554 WHERE intent_id = $1 AND decision_class = 'rebroadcast'"
16555 )))
16556 .bind(intent.id)
16557 .fetch_one(&admin_pool)
16558 .await
16559 .unwrap();
16560 assert_eq!(decision_count, 6);
16561
16562 drop_execution_schema(&admin_pool, &schema).await;
16563 }
16564
16565 #[rstest]
16566 #[case("execution_rebroadcast_false_test", CALL_ZERO)]
16567 #[case("execution_rebroadcast_revert_test", CALL_REVERTED)]
16568 #[tokio::test]
16569 async fn restart_suppresses_rebroadcast_when_simulation_denies(
16570 #[case] test_name: &str,
16571 #[case] simulation_response: &str,
16572 ) {
16573 let Some((admin_pool, schema, first_client, _)) =
16574 execution_client_with_database(test_name, ready_rpc_state()).await
16575 else {
16576 return;
16577 };
16578 let database = first_client.cache.database.as_ref().unwrap().clone();
16579 let (intent, _, _) =
16580 persist_test_wrap_broadcast(&database, first_client.payload_keys.as_deref()).await;
16581 let payload_keys = first_client.payload_keys.clone();
16582 drop(first_client);
16583
16584 let restart_state = execution_rpc_state()
16585 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
16586 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16587 .with_response("eth_call", simulation_response)
16588 .with_send_raw_transaction_echo();
16589 let addr = start_mock_rpc_server(restart_state.clone()).await;
16590 let mut restarted = test_client(format!("http://{addr}"));
16591 restarted.cache.database = Some(database);
16592 restarted.payload_keys = payload_keys;
16593 restarted.signer = Some(Arc::new(
16594 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16595 ));
16596
16597 restarted.reconcile_unresolved_execution().await.unwrap();
16598
16599 let requests = restart_state.recorded_requests();
16600 assert!(
16601 requests
16602 .iter()
16603 .all(|request| request["method"] != "eth_sendRawTransaction")
16604 );
16605 let simulation_calls = requests
16606 .iter()
16607 .filter(|request| {
16608 request["method"] == "eth_call"
16609 && request["params"][0]["data"] == "0xd0e30db0"
16610 && request["params"][1] == FIXTURE_BLOCK_PARAM
16611 })
16612 .count();
16613 assert_eq!(simulation_calls, 3);
16614 let decision_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16615 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16616 WHERE intent_id = $1 AND decision_class = 'rebroadcast'"
16617 )))
16618 .bind(intent.id)
16619 .fetch_one(&admin_pool)
16620 .await
16621 .unwrap();
16622 assert_eq!(decision_count, 7);
16623
16624 drop_execution_schema(&admin_pool, &schema).await;
16625 }
16626
16627 #[tokio::test]
16628 async fn restart_wrap_identity_mismatch_keeps_signer_ownership() {
16629 let initial_state = broadcast_rpc_state()
16630 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16631 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16632 let Some((admin_pool, schema, mut first_client, _)) =
16633 execution_client_with_database("execution_restart_mismatch_test", initial_state).await
16634 else {
16635 return;
16636 };
16637 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
16638 let error = first_client
16639 .wrap(U256::from(1_000_000_000_000_000_u64))
16640 .await
16641 .unwrap_err();
16642 assert!(
16643 error.to_string().contains("Timed out awaiting finality"),
16644 "was: {error}"
16645 );
16646 let database = first_client.cache.database.as_ref().unwrap().clone();
16647 let payload_keys = first_client.payload_keys.clone();
16648 drop(first_client);
16649
16650 let mismatched_block = serde_json::json!({
16652 "jsonrpc": "2.0",
16653 "id": 1,
16654 "result": {
16655 "number": "0x1cf0d41",
16656 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
16657 "parentHash": FIXTURE_BLOCK_HASH,
16658 "timestamp": "0x69044a21",
16659 "baseFeePerGas": "0x5f5e100",
16660 "transactions": [{
16661 "hash": expected_hash.to_string(),
16662 "from": WALLET,
16663 "nonce": "0x7",
16664 "chainId": "0xa4b1",
16665 "type": "0x2",
16666 "to": WETH,
16667 "input": "0xd0e30db0",
16668 "value": "0x0",
16669 "gas": "0x130b0",
16670 "maxFeePerGas": "0x7bfa480",
16671 "maxPriorityFeePerGas": "0x989680"
16672 }]
16673 }
16674 })
16675 .to_string();
16676 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
16677 let restart_state = with_finalized_identity(
16678 execution_rpc_state()
16679 .with_response("eth_getTransactionReceipt", &receipt)
16680 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &mismatched_block),
16681 &mismatched_block,
16682 &receipt,
16683 );
16684 let addr = start_mock_rpc_server(restart_state.clone()).await;
16685 let mut restarted = test_client(format!("http://{addr}"));
16686 restarted.cache.database = Some(database);
16687 restarted.payload_keys = payload_keys;
16688 restarted.signer = Some(Arc::new(
16689 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16690 ));
16691
16692 let error = restarted
16693 .reconcile_unresolved_execution()
16694 .await
16695 .unwrap_err();
16696
16697 assert!(
16698 error
16699 .to_string()
16700 .contains("finalized transaction identity mismatch"),
16701 "was: {error}"
16702 );
16703 let in_flight = awaiting_in_flight(&restarted);
16704 assert_eq!(in_flight.nonce, 7);
16705 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
16706 assert_eq!(in_flight.tx_hash, expected_hash);
16707 let requests = restart_state.recorded_requests();
16708 assert!(
16709 requests.iter().all(|request| {
16710 request["method"] != "eth_call"
16711 || !request["params"][0]["data"]
16712 .as_str()
16713 .is_some_and(|data| data.starts_with(BALANCE_OF_SELECTOR))
16714 }),
16715 "the postcondition must not run when call identity is unproven"
16716 );
16717 assert!(
16718 requests
16719 .iter()
16720 .all(|request| request["method"] != "eth_sendRawTransaction")
16721 );
16722 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
16723 "SELECT status, active FROM {schema}.execution_intent"
16724 )))
16725 .fetch_one(&admin_pool)
16726 .await
16727 .unwrap();
16728 assert_eq!(status, "dropped");
16729 assert!(active);
16730
16731 let database = restarted.cache.database.as_ref().unwrap().clone();
16732 let payload_keys = restarted.payload_keys.clone();
16733 drop(restarted);
16734 let mut second = test_client(format!("http://{addr}"));
16735 second.cache.database = Some(database);
16736 second.payload_keys = payload_keys;
16737 second.signer = Some(Arc::new(
16738 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16739 ));
16740 let error = second.reconcile_unresolved_execution().await.unwrap_err();
16741 assert!(
16742 error
16743 .to_string()
16744 .contains("finalized transaction identity mismatch"),
16745 "was: {error}"
16746 );
16747 let active: bool = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16748 "SELECT active FROM {schema}.execution_intent"
16749 )))
16750 .fetch_one(&admin_pool)
16751 .await
16752 .unwrap();
16753 assert!(active);
16754
16755 drop_execution_schema(&admin_pool, &schema).await;
16756 }
16757
16758 #[tokio::test]
16759 async fn restart_wrap_postcondition_failure_keeps_signer_ownership() {
16760 let initial_state = broadcast_rpc_state()
16761 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16762 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16763 let Some((admin_pool, schema, mut first_client, _)) =
16764 execution_client_with_database("execution_restart_postcondition_test", initial_state)
16765 .await
16766 else {
16767 return;
16768 };
16769 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
16770 let error = first_client
16771 .wrap(U256::from(1_000_000_000_000_000_u64))
16772 .await
16773 .unwrap_err();
16774 assert!(
16775 error.to_string().contains("Timed out awaiting finality"),
16776 "was: {error}"
16777 );
16778 let database = first_client.cache.database.as_ref().unwrap().clone();
16779 let payload_keys = first_client.payload_keys.clone();
16780 drop(first_client);
16781
16782 let failing_wrap_state = || {
16784 let block = finalized_wrap_block(expected_hash);
16785 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
16786 with_finalized_identity(
16787 execution_rpc_state()
16788 .with_response("eth_getTransactionReceipt", &receipt)
16789 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
16790 .with_response_sequence("eth_call", &[CALL_BALANCE; 6]),
16791 &block,
16792 &receipt,
16793 )
16794 };
16795 let restart_state = failing_wrap_state();
16796 let addr = start_mock_rpc_server(restart_state.clone()).await;
16797 let mut restarted = test_client(format!("http://{addr}"));
16798 restarted.cache.database = Some(database);
16799 restarted.payload_keys = payload_keys;
16800 restarted.signer = Some(Arc::new(
16801 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16802 ));
16803
16804 let error = restarted
16805 .reconcile_unresolved_execution()
16806 .await
16807 .unwrap_err();
16808
16809 assert!(
16810 error.to_string().contains("did not increase by"),
16811 "was: {error}"
16812 );
16813 let in_flight = awaiting_in_flight(&restarted);
16814 assert_eq!(in_flight.nonce, 7);
16815 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
16816 assert!(
16817 restart_state
16818 .recorded_requests()
16819 .iter()
16820 .all(|request| request["method"] != "eth_sendRawTransaction")
16821 );
16822 assert_eq!(
16823 execution_intent_markers(&admin_pool, &schema).await,
16824 vec![("wrap".into(), "dropped".into(), false, true)]
16825 );
16826
16827 let addr = start_mock_rpc_server(failing_wrap_state()).await;
16828 let error = later_reconnect(restarted, format!("http://{addr}")).await;
16829 assert!(
16830 error.to_string().contains("did not increase by"),
16831 "was: {error}"
16832 );
16833 assert_eq!(
16834 execution_intent_markers(&admin_pool, &schema).await,
16835 vec![("wrap".into(), "dropped".into(), false, true)]
16836 );
16837
16838 drop_execution_schema(&admin_pool, &schema).await;
16839 }
16840
16841 #[tokio::test]
16842 async fn restart_wrap_revert_marks_terminal_and_releases() {
16843 let initial_state = broadcast_rpc_state()
16844 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16845 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16846 let Some((admin_pool, schema, mut first_client, _)) =
16847 execution_client_with_database("execution_restart_wrap_revert_test", initial_state)
16848 .await
16849 else {
16850 return;
16851 };
16852 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
16853 let error = first_client
16854 .wrap(U256::from(1_000_000_000_000_000_u64))
16855 .await
16856 .unwrap_err();
16857 assert!(
16858 error.to_string().contains("Timed out awaiting finality"),
16859 "was: {error}"
16860 );
16861 let database = first_client.cache.database.as_ref().unwrap().clone();
16862 let payload_keys = first_client.payload_keys.clone();
16863 drop(first_client);
16864
16865 let block = finalized_wrap_block(expected_hash);
16866 let receipt = receipt_with_transaction_hash(RECEIPT_REVERTED, expected_hash);
16867 let restart_state = with_finalized_identity(
16868 execution_rpc_state()
16869 .with_response("eth_getTransactionReceipt", &receipt)
16870 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block),
16871 &block,
16872 &receipt,
16873 );
16874 let addr = start_mock_rpc_server(restart_state.clone()).await;
16875 let mut restarted = test_client(format!("http://{addr}"));
16876 restarted.cache.database = Some(database);
16877 restarted.payload_keys = payload_keys;
16878 restarted.signer = Some(Arc::new(
16879 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16880 ));
16881
16882 restarted.reconcile_unresolved_execution().await.unwrap();
16883
16884 assert!(restarted.in_flight.lock().is_none());
16885 assert!(
16886 restart_state
16887 .recorded_requests()
16888 .iter()
16889 .all(|request| request["method"] != "eth_sendRawTransaction")
16890 );
16891 assert_eq!(
16892 execution_intent_markers(&admin_pool, &schema).await,
16893 vec![("wrap".into(), "reverted".into(), true, false)]
16894 );
16895
16896 drop_execution_schema(&admin_pool, &schema).await;
16897 }
16898
16899 #[tokio::test]
16900 async fn restart_quarantines_unretained_same_nonce_wrap_replacement() {
16901 let initial_state = broadcast_rpc_state()
16902 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16903 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16904 let Some((admin_pool, schema, mut first_client, _)) =
16905 execution_client_with_database("execution_restart_replacement_test", initial_state)
16906 .await
16907 else {
16908 return;
16909 };
16910 let original_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
16911 let error = first_client
16912 .wrap(U256::from(1_000_000_000_000_000_u64))
16913 .await
16914 .unwrap_err();
16915 assert!(
16916 error.to_string().contains("Timed out awaiting finality"),
16917 "was: {error}"
16918 );
16919 let database = first_client.cache.database.as_ref().unwrap().clone();
16920 let payload_keys = first_client.payload_keys.clone();
16921 drop(first_client);
16922
16923 let replacement_hash = B256::from([0x44; 32]);
16925 let restart_state = execution_rpc_state()
16926 .with_response("eth_getTransactionCount", TRANSACTION_COUNT_NEXT)
16927 .with_response_sequence("eth_call", &[CALL_BALANCE, CALL_BALANCE_AFTER_WRAP])
16928 .with_response_sequence(
16929 "eth_getTransactionReceipt",
16930 &[RECEIPT_NULL, RECEIPT_NULL, RECEIPT_NULL],
16931 )
16932 .with_parameter_response(
16933 "eth_getBlockByNumber",
16934 "0x1cf0d40",
16935 &replacement_head_block(replacement_hash),
16936 )
16937 .with_parameter_response(
16938 "eth_getBlockByNumber",
16939 "0x1cf0d41",
16940 &finalized_wrap_block(replacement_hash),
16941 );
16942 let addr = start_mock_rpc_server(restart_state.clone()).await;
16943 let mut restarted = test_client(format!("http://{addr}"));
16944 restarted.cache.database = Some(database);
16945 restarted.payload_keys = payload_keys;
16946 restarted.signer = Some(Arc::new(
16947 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16948 ));
16949 restarted.transaction_limits.receipt_timeout_secs = 2;
16950
16951 let error = restarted
16952 .reconcile_unresolved_execution()
16953 .await
16954 .unwrap_err();
16955
16956 let hashes: Vec<(String, String, bool)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
16957 "SELECT transaction_hash, status, current FROM \
16958 {schema}.execution_transaction_hash ORDER BY id"
16959 )))
16960 .fetch_all(&admin_pool)
16961 .await
16962 .unwrap();
16963 let requests = restart_state.recorded_requests();
16964
16965 assert!(
16966 error
16967 .to_string()
16968 .contains("has no authenticated retained payload"),
16969 "was: {error}"
16970 );
16971 assert_eq!(
16972 hashes,
16973 [(original_hash.to_string(), "dropped".to_string(), true)]
16974 );
16975 assert!(restarted.in_flight.lock().is_some());
16976 assert_eq!(
16977 execution_intent_markers(&admin_pool, &schema).await,
16978 vec![("wrap".into(), "dropped".into(), false, true)]
16979 );
16980 assert_eq!(
16981 requests
16982 .iter()
16983 .filter(|request| request["method"] == "eth_getTransactionReceipt")
16984 .count(),
16985 3
16986 );
16987 assert!(requests.iter().all(|request| {
16988 request["method"] != "eth_call" || request["params"][0]["data"] != "0xd0e30db0"
16989 }));
16990 assert!(
16991 requests
16992 .iter()
16993 .all(|request| request["method"] != "eth_sendRawTransaction")
16994 );
16995
16996 drop_execution_schema(&admin_pool, &schema).await;
16997 }
16998
16999 #[tokio::test]
17000 async fn replacement_scan_accepts_only_an_authenticated_retained_payload() {
17001 let expected_hash = expected_wrap_tx_hash(U256::from(1_u64)).await;
17002 let mut replacement_block: serde_json::Value =
17003 serde_json::from_str(&replacement_head_block(expected_hash)).unwrap();
17004 replacement_block["result"]["transactions"][1]["value"] = serde_json::json!("0x1");
17005 let replacement_block = replacement_block.to_string();
17006 let state = execution_rpc_state().with_parameter_response(
17007 "eth_getBlockByNumber",
17008 "0x1cf0d40",
17009 &replacement_block,
17010 );
17011 let Some((admin_pool, schema, client, _)) =
17012 execution_client_with_database("replacement_scan_authenticated", state).await
17013 else {
17014 return;
17015 };
17016 let database = client.cache.database.as_ref().unwrap();
17017 let (intent, persisted_hash, persisted_raw) =
17018 persist_test_wrap_broadcast(database, client.payload_keys.as_deref()).await;
17019 assert_eq!(persisted_hash, expected_hash);
17020 let authenticated_payloads = HashMap::from([(expected_hash, persisted_raw.clone())]);
17021 let head = VerifiedBlockHeader {
17022 number: FIXTURE_BLOCK,
17023 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
17024 parent_hash: B256::from_str(
17025 "0x0000000000000000000000000000000000000000000000000000000000000001",
17026 )
17027 .unwrap(),
17028 timestamp: FIXTURE_BLOCK_TIMESTAMP,
17029 base_fee_per_gas: Some(100_000_000),
17030 };
17031
17032 let matched = client
17033 .transaction_executor()
17034 .unwrap()
17035 .scan_canonical_replacement(&intent, 7, head, &authenticated_payloads)
17036 .await
17037 .unwrap()
17038 .unwrap();
17039
17040 assert_eq!(matched, (expected_hash, persisted_raw));
17041 let cursor: (i64, String) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
17042 "SELECT finalized_cursor_number, finalized_cursor_hash \
17043 FROM {schema}.execution_replacement_scan WHERE intent_id = $1"
17044 )))
17045 .bind(intent.id)
17046 .fetch_one(&admin_pool)
17047 .await
17048 .unwrap();
17049 assert_eq!(
17050 cursor,
17051 (FIXTURE_BLOCK as i64, FIXTURE_BLOCK_HASH.to_string())
17052 );
17053 let evidence_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17054 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
17055 WHERE intent_id = $1 AND decision_class = 'replacement_scan'"
17056 )))
17057 .bind(intent.id)
17058 .fetch_one(&admin_pool)
17059 .await
17060 .unwrap();
17061 assert_eq!(evidence_count, 2);
17062
17063 drop_execution_schema(&admin_pool, &schema).await;
17064 }
17065
17066 #[tokio::test]
17067 async fn restart_reconciles_finalized_approve_after_validation() {
17068 let initial_state = broadcast_rpc_state()
17069 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
17070 .with_response("eth_call", CALL_BOOL_TRUE)
17071 .with_call_response_sequence(ALLOWANCE_SELECTOR, &[CALL_ZERO; 3])
17072 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO);
17073 let Some((admin_pool, schema, mut first_client, _)) =
17074 execution_client_with_database("execution_restart_approve_test", initial_state).await
17075 else {
17076 return;
17077 };
17078 let expected_hash = expected_approve_tx_hash(U256::from(1_000u64)).await;
17079 let error = first_client
17080 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
17081 .await
17082 .unwrap_err();
17083 assert!(
17084 error.to_string().contains("Timed out awaiting finality"),
17085 "was: {error}"
17086 );
17087 let database = first_client.cache.database.as_ref().unwrap().clone();
17088 let payload_keys = first_client.payload_keys.clone();
17089 drop(first_client);
17090
17091 let block = finalized_approve_block(expected_hash, U256::from(1_000u64));
17092 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
17093 let restart_state = with_finalized_identity(
17094 execution_rpc_state()
17095 .with_response("eth_getTransactionReceipt", &receipt)
17096 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
17097 .with_call_response(ALLOWANCE_SELECTOR, CALL_ALLOWANCE_1000),
17098 &block,
17099 &receipt,
17100 );
17101 let addr = start_mock_rpc_server(restart_state.clone()).await;
17102 let mut restarted = test_client(format!("http://{addr}"));
17103 restarted.cache.database = Some(database);
17104 restarted.payload_keys = payload_keys;
17105 restarted.signer = Some(Arc::new(
17106 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
17107 ));
17108
17109 restarted.reconcile_unresolved_execution().await.unwrap();
17110
17111 let record = restarted
17112 .cache
17113 .get_execution_transaction(42161, &expected_hash.to_string())
17114 .await
17115 .unwrap()
17116 .unwrap();
17117 let requests = restart_state.recorded_requests();
17118 assert_eq!(record.status, "finalized");
17119 assert_eq!(record.purpose, "approve");
17120 assert!(restarted.in_flight.lock().is_none());
17121 assert_eq!(
17122 execution_intent_markers(&admin_pool, &schema).await,
17123 vec![("approve".into(), "finalized".into(), true, false)]
17124 );
17125 assert_eq!(
17126 requests
17127 .iter()
17128 .filter(|request| {
17129 request["method"] == "eth_call"
17130 && request["params"][0]["data"]
17131 .as_str()
17132 .is_some_and(|data| data.starts_with(ALLOWANCE_SELECTOR))
17133 })
17134 .count(),
17135 3
17136 );
17137 assert_eq!(
17138 requests
17139 .iter()
17140 .filter(|request| request["method"] == "eth_sendRawTransaction")
17141 .count(),
17142 0
17143 );
17144
17145 drop_execution_schema(&admin_pool, &schema).await;
17146 }
17147
17148 #[tokio::test]
17149 async fn restart_approve_postcondition_failure_keeps_signer_ownership() {
17150 let initial_state = broadcast_rpc_state()
17151 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
17152 .with_response("eth_call", CALL_BOOL_TRUE)
17153 .with_call_response_sequence(ALLOWANCE_SELECTOR, &[CALL_ZERO; 3])
17154 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO);
17155 let Some((admin_pool, schema, mut first_client, _)) =
17156 execution_client_with_database("execution_restart_approve_post_test", initial_state)
17157 .await
17158 else {
17159 return;
17160 };
17161 let expected_hash = expected_approve_tx_hash(U256::from(1_000u64)).await;
17162 let error = first_client
17163 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
17164 .await
17165 .unwrap_err();
17166 assert!(
17167 error.to_string().contains("Timed out awaiting finality"),
17168 "was: {error}"
17169 );
17170 let database = first_client.cache.database.as_ref().unwrap().clone();
17171 let payload_keys = first_client.payload_keys.clone();
17172 drop(first_client);
17173
17174 let block = finalized_approve_block(expected_hash, U256::from(1_000u64));
17176 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
17177 let restart_state = with_finalized_identity(
17178 execution_rpc_state()
17179 .with_response("eth_getTransactionReceipt", &receipt)
17180 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
17181 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO),
17182 &block,
17183 &receipt,
17184 );
17185 let addr = start_mock_rpc_server(restart_state.clone()).await;
17186 let mut restarted = test_client(format!("http://{addr}"));
17187 restarted.cache.database = Some(database);
17188 restarted.payload_keys = payload_keys;
17189 restarted.signer = Some(Arc::new(
17190 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
17191 ));
17192
17193 let error = restarted
17194 .reconcile_unresolved_execution()
17195 .await
17196 .unwrap_err();
17197
17198 assert!(
17199 error
17200 .to_string()
17201 .contains("does not equal the requested amount"),
17202 "was: {error}"
17203 );
17204 let in_flight = awaiting_in_flight(&restarted);
17205 assert_eq!(in_flight.nonce, 7);
17206 assert_eq!(in_flight.purpose, TransactionPurpose::Approve);
17207 assert!(
17208 restart_state
17209 .recorded_requests()
17210 .iter()
17211 .all(|request| request["method"] != "eth_sendRawTransaction")
17212 );
17213 assert_eq!(
17214 execution_intent_markers(&admin_pool, &schema).await,
17215 vec![("approve".into(), "dropped".into(), false, true)]
17216 );
17217
17218 let error = later_reconnect(restarted, format!("http://{addr}")).await;
17219 assert!(
17220 error
17221 .to_string()
17222 .contains("does not equal the requested amount"),
17223 "was: {error}"
17224 );
17225 assert_eq!(
17226 execution_intent_markers(&admin_pool, &schema).await,
17227 vec![("approve".into(), "dropped".into(), false, true)]
17228 );
17229
17230 drop_execution_schema(&admin_pool, &schema).await;
17231 }
17232
17233 #[tokio::test]
17234 async fn disappearing_unfinalized_receipt_drops_without_committing_inclusion() {
17235 let state = execution_rpc_state()
17236 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
17237 .with_response("eth_estimateGas", ESTIMATE_GAS)
17238 .with_response("eth_call", CALL_BALANCE)
17239 .with_response_sequence(
17240 "eth_getTransactionReceipt",
17241 &[
17242 RECEIPT_SUCCESS,
17243 RECEIPT_SUCCESS,
17244 RECEIPT_SUCCESS,
17245 RECEIPT_NULL,
17246 RECEIPT_NULL,
17247 RECEIPT_NULL,
17248 ],
17249 )
17250 .with_parameter_response("eth_getBlockByNumber", "finalized", BLOCK_BY_NUMBER)
17251 .with_send_raw_transaction_echo();
17252 let Some((admin_pool, schema, mut client, _)) =
17253 execution_client_with_database("execution_receipt_disappeared_test", state).await
17254 else {
17255 return;
17256 };
17257 client.transaction_limits.receipt_timeout_secs = 2;
17258
17259 let error = client
17260 .wrap(U256::from(1_000_000_000_000_000_u64))
17261 .await
17262 .unwrap_err();
17263 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17264 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
17265 )))
17266 .fetch_all(&admin_pool)
17267 .await
17268 .unwrap();
17269
17270 assert!(
17271 error.to_string().contains("Timed out awaiting finality"),
17272 "was: {error}"
17273 );
17274 assert_eq!(transitions, ["prepared", "signed", "broadcast", "dropped"]);
17275 assert!(client.in_flight.lock().is_some());
17276
17277 drop_execution_schema(&admin_pool, &schema).await;
17278 }
17279
17280 #[tokio::test]
17281 async fn changed_unfinalized_block_drops_without_committing_inclusion() {
17282 let changed_block = serde_json::json!({
17283 "jsonrpc": "2.0",
17284 "id": 1,
17285 "result": {
17286 "number": "0x1cf0d41",
17287 "hash": "0x4444444444444444444444444444444444444444444444444444444444444444",
17288 "parentHash": FIXTURE_BLOCK_HASH,
17289 "timestamp": "0x69044a21",
17290 "baseFeePerGas": "0x5f5e100",
17291 "transactions": []
17292 }
17293 })
17294 .to_string();
17295 let state = execution_rpc_state()
17296 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
17297 .with_response("eth_estimateGas", ESTIMATE_GAS)
17298 .with_response("eth_call", CALL_BALANCE)
17299 .with_response_sequence(
17300 "eth_getTransactionReceipt",
17301 &[
17302 RECEIPT_SUCCESS,
17303 RECEIPT_SUCCESS,
17304 RECEIPT_SUCCESS,
17305 RECEIPT_SUCCESS,
17306 RECEIPT_SUCCESS,
17307 RECEIPT_SUCCESS,
17308 ],
17309 )
17310 .with_parameter_response_sequence(
17311 "eth_getBlockByNumber",
17312 "0x1cf0d41",
17313 &[
17314 BLOCK_CANONICAL,
17315 BLOCK_CANONICAL,
17316 BLOCK_CANONICAL,
17317 &changed_block,
17318 &changed_block,
17319 &changed_block,
17320 ],
17321 )
17322 .with_parameter_response("eth_getBlockByNumber", "finalized", BLOCK_BY_NUMBER)
17323 .with_send_raw_transaction_echo();
17324 let Some((admin_pool, schema, mut client, _)) =
17325 execution_client_with_database("execution_reorg_test", state).await
17326 else {
17327 return;
17328 };
17329 client.transaction_limits.receipt_timeout_secs = 2;
17330
17331 let error = client
17332 .wrap(U256::from(1_000_000_000_000_000_u64))
17333 .await
17334 .unwrap_err();
17335 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17336 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
17337 )))
17338 .fetch_all(&admin_pool)
17339 .await
17340 .unwrap();
17341
17342 assert!(
17343 error.to_string().contains("Timed out awaiting finality"),
17344 "was: {error}"
17345 );
17346 assert_eq!(transitions, ["prepared", "signed", "broadcast", "dropped"]);
17347 assert!(client.in_flight.lock().is_some());
17348
17349 drop_execution_schema(&admin_pool, &schema).await;
17350 }
17351
17352 #[tokio::test]
17353 async fn pre_sign_pending_nonce_drift_blocks_wrap_before_signature() {
17354 let state = execution_rpc_state()
17355 .with_response_sequence(
17356 "eth_getTransactionCount",
17357 &[
17358 TRANSACTION_COUNT,
17359 TRANSACTION_COUNT,
17360 TRANSACTION_COUNT,
17361 TRANSACTION_COUNT_NEXT,
17362 TRANSACTION_COUNT_NEXT,
17363 TRANSACTION_COUNT_NEXT,
17364 ],
17365 )
17366 .with_response("eth_estimateGas", ESTIMATE_GAS)
17367 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE)
17368 .with_send_raw_transaction_echo();
17369 let Some((admin_pool, schema, mut client, state)) =
17370 execution_client_with_database("pre_sign_pending_nonce_drift", state).await
17371 else {
17372 return;
17373 };
17374
17375 let error = client
17376 .wrap(U256::from(1_000_000_000_000_000_u64))
17377 .await
17378 .unwrap_err();
17379 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17380 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
17381 )))
17382 .fetch_one(&admin_pool)
17383 .await
17384 .unwrap();
17385
17386 assert!(
17387 error
17388 .to_string()
17389 .contains("Pending nonce does not match the verified canonical nonce"),
17390 "was: {error}"
17391 );
17392 assert_eq!(signed_count, 0);
17393 assert!(
17394 state
17395 .recorded_requests()
17396 .iter()
17397 .all(|request| request["method"] != "eth_sendRawTransaction")
17398 );
17399 assert!(client.in_flight.lock().is_none());
17400
17401 drop_execution_schema(&admin_pool, &schema).await;
17402 }
17403
17404 #[tokio::test]
17405 async fn unknown_same_nonce_swap_replacement_emits_no_rejection() {
17406 let replacement_hash = B256::from([0x44; 32]);
17407 let replacement_block = replacement_head_block(replacement_hash);
17408 let state = execution_rpc_state()
17409 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d40", &replacement_block)
17410 .with_send_raw_transaction_echo();
17411 let Some((admin_pool, schema, mut client, _, _)) =
17412 swap_client_with_database("unknown_same_nonce_swap_replacement", state).await
17413 else {
17414 return;
17415 };
17416 let mut receiver = start_with_events(&mut client);
17417 let database = client.cache.database.as_ref().unwrap();
17418 let (intent, original_hash, original_payload) =
17419 persist_test_swap_broadcast(database, client.payload_keys.as_deref()).await;
17420 let authenticated_payloads = HashMap::from([(original_hash, original_payload)]);
17421 let head = VerifiedBlockHeader {
17422 number: FIXTURE_BLOCK,
17423 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
17424 parent_hash: B256::from_str(
17425 "0x0000000000000000000000000000000000000000000000000000000000000001",
17426 )
17427 .unwrap(),
17428 timestamp: FIXTURE_BLOCK_TIMESTAMP,
17429 base_fee_per_gas: Some(100_000_000),
17430 };
17431
17432 let error = client
17433 .transaction_executor()
17434 .unwrap()
17435 .scan_canonical_replacement(&intent, 7, head, &authenticated_payloads)
17436 .await
17437 .unwrap_err();
17438 let events = collect_order_events(&mut receiver);
17439 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
17440 "SELECT status, active FROM {schema}.execution_intent"
17441 )))
17442 .fetch_one(&admin_pool)
17443 .await
17444 .unwrap();
17445
17446 assert!(
17447 error
17448 .to_string()
17449 .contains("has no authenticated retained payload"),
17450 "was: {error}"
17451 );
17452 assert!(events.is_empty(), "was: {events:?}");
17453 assert_eq!(status, "broadcast");
17454 assert!(active);
17455
17456 drop_execution_schema(&admin_pool, &schema).await;
17457 }
17458
17459 #[tokio::test]
17460 async fn replacement_scan_rejects_rpc_fields_that_conflict_with_the_payload() {
17461 let expected_hash = expected_wrap_tx_hash(U256::from(1_u64)).await;
17462 let mut replacement_block: serde_json::Value =
17463 serde_json::from_str(&replacement_head_block(expected_hash)).unwrap();
17464 replacement_block["result"]["transactions"][1]["value"] = serde_json::json!("0x2");
17465 let replacement_block = replacement_block.to_string();
17466 let state = execution_rpc_state().with_parameter_response(
17467 "eth_getBlockByNumber",
17468 "0x1cf0d40",
17469 &replacement_block,
17470 );
17471 let Some((admin_pool, schema, client, _)) =
17472 execution_client_with_database("replacement_scan_payload_mismatch", state).await
17473 else {
17474 return;
17475 };
17476 let database = client.cache.database.as_ref().unwrap();
17477 let (intent, persisted_hash, persisted_payload) =
17478 persist_test_wrap_broadcast(database, client.payload_keys.as_deref()).await;
17479 assert_eq!(persisted_hash, expected_hash);
17480 let authenticated_payloads = HashMap::from([(persisted_hash, persisted_payload)]);
17481 let head = VerifiedBlockHeader {
17482 number: FIXTURE_BLOCK,
17483 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
17484 parent_hash: B256::from_str(
17485 "0x0000000000000000000000000000000000000000000000000000000000000001",
17486 )
17487 .unwrap(),
17488 timestamp: FIXTURE_BLOCK_TIMESTAMP,
17489 base_fee_per_gas: Some(100_000_000),
17490 };
17491
17492 let error = client
17493 .transaction_executor()
17494 .unwrap()
17495 .scan_canonical_replacement(&intent, 7, head, &authenticated_payloads)
17496 .await
17497 .unwrap_err();
17498 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
17499 "SELECT status, active FROM {schema}.execution_intent"
17500 )))
17501 .fetch_one(&admin_pool)
17502 .await
17503 .unwrap();
17504
17505 assert!(
17506 error
17507 .to_string()
17508 .contains("failed authenticated payload validation"),
17509 "was: {error}"
17510 );
17511 assert_eq!(status, "broadcast");
17512 assert!(active);
17513
17514 drop_execution_schema(&admin_pool, &schema).await;
17515 }
17516
17517 #[tokio::test]
17518 async fn execution_transaction_constraints_reject_conflicting_identity() {
17519 const TRANSACTION_HASH: &str = "0xduplicate-transaction-hash";
17520 const OTHER_WALLET: &str = "0x0000000000000000000000000000000000000001";
17521 let Some((admin_pool, schema, client, _)) = execution_client_with_unprotected_database(
17522 "execution_duplicate_record_test",
17523 ready_rpc_state(),
17524 )
17525 .await
17526 else {
17527 return;
17528 };
17529 let database = client.cache.database.as_ref().unwrap();
17530 let operator = database
17531 .reserve_execution_intent(&ExecutionIntentInsert {
17532 chain_id: 42161,
17533 wallet_address: WALLET.to_string(),
17534 purpose: "wrap".to_string(),
17535 client_order_id: None,
17536 trader_id: None,
17537 strategy_id: None,
17538 account_id: None,
17539 instrument_id: None,
17540 pool_address: None,
17541 transaction_to: WETH_ADDRESS.to_string(),
17542 transaction_input: "0xd0e30db0".to_string(),
17543 transaction_value: "1".to_string(),
17544 amount_in: None,
17545 created_block: FIXTURE_BLOCK,
17546 })
17547 .await
17548 .unwrap();
17549 database
17550 .assign_execution_intent_nonce(operator.id, 7)
17551 .await
17552 .unwrap();
17553 database
17554 .add_execution_transaction_hash(operator.id, 42161, TRANSACTION_HASH, &[1, 2, 3])
17555 .await
17556 .unwrap();
17557 database
17558 .add_execution_transaction_hash(operator.id, 42161, TRANSACTION_HASH, &[1, 2, 3])
17559 .await
17560 .unwrap();
17561
17562 let signer_conflict = database
17563 .reserve_execution_intent(&ExecutionIntentInsert {
17564 chain_id: 42161,
17565 wallet_address: WALLET.to_string(),
17566 purpose: "approve".to_string(),
17567 client_order_id: None,
17568 trader_id: None,
17569 strategy_id: None,
17570 account_id: None,
17571 instrument_id: None,
17572 pool_address: None,
17573 transaction_to: ROUTER_ADDRESS.to_string(),
17574 transaction_input: "0x01".to_string(),
17575 transaction_value: "0".to_string(),
17576 amount_in: None,
17577 created_block: FIXTURE_BLOCK,
17578 })
17579 .await
17580 .unwrap_err();
17581 let other = database
17582 .reserve_execution_intent(&ExecutionIntentInsert {
17583 chain_id: 42161,
17584 wallet_address: OTHER_WALLET.to_string(),
17585 purpose: "approve".to_string(),
17586 client_order_id: None,
17587 trader_id: None,
17588 strategy_id: None,
17589 account_id: None,
17590 instrument_id: None,
17591 pool_address: None,
17592 transaction_to: ROUTER_ADDRESS.to_string(),
17593 transaction_input: "0x01".to_string(),
17594 transaction_value: "0".to_string(),
17595 amount_in: None,
17596 created_block: FIXTURE_BLOCK,
17597 })
17598 .await
17599 .unwrap();
17600 database
17601 .assign_execution_intent_nonce(other.id, 7)
17602 .await
17603 .unwrap();
17604 let hash_conflict = database
17605 .add_execution_transaction_hash(other.id, 42161, TRANSACTION_HASH, &[4, 5, 6])
17606 .await
17607 .unwrap_err();
17608
17609 let record = database
17610 .get_execution_transaction(42161, TRANSACTION_HASH)
17611 .await
17612 .unwrap()
17613 .unwrap();
17614 let count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17615 "SELECT COUNT(*) FROM {schema}.execution_intent"
17616 )))
17617 .fetch_one(&admin_pool)
17618 .await
17619 .unwrap();
17620
17621 assert_eq!(record.wallet_address.as_deref(), Some(WALLET));
17622 assert_eq!(record.nonce, 7);
17623 assert_eq!(record.transaction_hash, TRANSACTION_HASH);
17624 assert_eq!(record.purpose, "wrap");
17625 assert_eq!(record.status, "signed");
17626 assert_eq!(record.client_order_id, None);
17627 assert!(
17628 hash_conflict
17629 .to_string()
17630 .contains("conflicts with its persisted identity"),
17631 "was: {hash_conflict}"
17632 );
17633 assert_eq!(
17634 signer_conflict.to_string(),
17635 "Execution intent reservation failed before commit"
17636 );
17637 assert!(
17638 signer_conflict.chain().any(|cause| cause
17639 .to_string()
17640 .contains("execution_intent_active_signer_key")),
17641 "was: {signer_conflict:#}"
17642 );
17643 assert_eq!(count, 2);
17644
17645 drop_execution_schema(&admin_pool, &schema).await;
17646 }
17647
17648 #[tokio::test]
17649 async fn execution_status_transitions_are_idempotent() {
17650 const TRANSACTION_HASH: &str =
17651 "0x5555555555555555555555555555555555555555555555555555555555555555";
17652 let Some((admin_pool, schema, client, _)) = execution_client_with_unprotected_database(
17653 "execution_transition_test",
17654 ready_rpc_state(),
17655 )
17656 .await
17657 else {
17658 return;
17659 };
17660 let database = client.cache.database.as_ref().unwrap();
17661 let intent = database
17662 .reserve_execution_intent(&ExecutionIntentInsert {
17663 chain_id: 42161,
17664 wallet_address: WALLET.to_string(),
17665 purpose: "wrap".to_string(),
17666 client_order_id: None,
17667 trader_id: None,
17668 strategy_id: None,
17669 account_id: None,
17670 instrument_id: None,
17671 pool_address: None,
17672 transaction_to: WETH_ADDRESS.to_string(),
17673 transaction_input: "0xd0e30db0".to_string(),
17674 transaction_value: "1".to_string(),
17675 amount_in: None,
17676 created_block: FIXTURE_BLOCK,
17677 })
17678 .await
17679 .unwrap();
17680 database
17681 .assign_execution_intent_nonce(intent.id, 7)
17682 .await
17683 .unwrap();
17684 database
17685 .add_execution_transaction_hash(intent.id, 42161, TRANSACTION_HASH, &[1, 2, 3])
17686 .await
17687 .unwrap();
17688
17689 for _ in 0..2 {
17690 database
17691 .record_execution_status(
17692 intent.id,
17693 TRANSACTION_HASH,
17694 TransactionStatus::Broadcast,
17695 None,
17696 None,
17697 None,
17698 None,
17699 None,
17700 )
17701 .await
17702 .unwrap();
17703 }
17704
17705 for status in [TransactionStatus::Included, TransactionStatus::Included] {
17706 database
17707 .record_execution_status(
17708 intent.id,
17709 TRANSACTION_HASH,
17710 status,
17711 Some(FIXTURE_BLOCK + 1),
17712 Some("0x2222222222222222222222222222222222222222222222222222222222222222"),
17713 Some(true),
17714 Some(50_112),
17715 Some("100000000"),
17716 )
17717 .await
17718 .unwrap();
17719 }
17720
17721 for _ in 0..2 {
17722 database
17723 .record_execution_status(
17724 intent.id,
17725 TRANSACTION_HASH,
17726 TransactionStatus::Finalized,
17727 Some(FIXTURE_BLOCK + 1),
17728 Some("0x2222222222222222222222222222222222222222222222222222222222222222"),
17729 Some(true),
17730 Some(50_112),
17731 Some("100000000"),
17732 )
17733 .await
17734 .unwrap();
17735 }
17736
17737 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17738 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
17739 )))
17740 .fetch_all(&admin_pool)
17741 .await
17742 .unwrap();
17743 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
17744 "SELECT status, active FROM {schema}.execution_intent"
17745 )))
17746 .fetch_one(&admin_pool)
17747 .await
17748 .unwrap();
17749 let append_only_error = sqlx::query(sqlx::AssertSqlSafe(format!(
17750 "DELETE FROM {schema}.execution_transaction_transition WHERE intent_id = {}",
17751 intent.id
17752 )))
17753 .execute(&admin_pool)
17754 .await
17755 .unwrap_err();
17756
17757 assert_eq!(
17758 transitions,
17759 ["prepared", "signed", "broadcast", "included", "finalized"]
17760 );
17761 assert_eq!(status, "finalized");
17762 assert!(active);
17763 assert!(
17764 append_only_error
17765 .to_string()
17766 .contains("Execution transitions are append-only"),
17767 "was: {append_only_error}"
17768 );
17769
17770 drop_execution_schema(&admin_pool, &schema).await;
17771 }
17772
17773 #[tokio::test]
17774 async fn wrap_then_approve_persists_records_and_clears_in_flight() {
17775 let state = execution_rpc_state()
17776 .with_parameter_response_sequence(
17777 "eth_getBlockByNumber",
17778 "latest",
17779 &[
17780 BLOCK_BY_NUMBER,
17781 BLOCK_BY_NUMBER,
17782 BLOCK_BY_NUMBER,
17783 BLOCK_FINALIZED,
17784 BLOCK_FINALIZED,
17785 BLOCK_FINALIZED,
17786 ],
17787 )
17788 .with_response_sequence(
17789 "eth_call",
17790 &[
17791 CALL_BALANCE,
17792 CALL_BALANCE,
17793 CALL_BALANCE,
17794 CALL_BALANCE,
17795 CALL_BALANCE,
17796 CALL_BALANCE,
17797 CALL_BALANCE_AFTER_WRAP,
17798 CALL_BALANCE_AFTER_WRAP,
17799 CALL_BALANCE_AFTER_WRAP,
17800 CALL_BOOL_TRUE,
17801 CALL_BOOL_TRUE,
17802 CALL_BOOL_TRUE,
17803 ],
17804 )
17805 .with_call_response_sequence(
17806 ALLOWANCE_SELECTOR,
17807 &[
17808 CALL_ZERO,
17809 CALL_ZERO,
17810 CALL_ZERO,
17811 CALL_ALLOWANCE_MAX,
17812 CALL_ALLOWANCE_MAX,
17813 CALL_ALLOWANCE_MAX,
17814 ],
17815 )
17816 .with_response_sequence(
17817 "eth_getTransactionCount",
17818 &[
17819 TRANSACTION_COUNT,
17820 TRANSACTION_COUNT,
17821 TRANSACTION_COUNT,
17822 TRANSACTION_COUNT,
17823 TRANSACTION_COUNT,
17824 TRANSACTION_COUNT,
17825 TRANSACTION_COUNT_NEXT,
17826 TRANSACTION_COUNT_NEXT,
17827 TRANSACTION_COUNT_NEXT,
17828 TRANSACTION_COUNT_NEXT,
17829 TRANSACTION_COUNT_NEXT,
17830 TRANSACTION_COUNT_NEXT,
17831 ],
17832 )
17833 .with_response("eth_estimateGas", ESTIMATE_GAS)
17834 .with_response_sequence(
17835 "eth_getTransactionReceipt",
17836 &[
17837 RECEIPT_SUCCESS,
17838 RECEIPT_SUCCESS,
17839 RECEIPT_SUCCESS,
17840 RECEIPT_SUCCESS,
17841 RECEIPT_SUCCESS,
17842 RECEIPT_SUCCESS,
17843 ],
17844 )
17845 .with_send_raw_transaction_echo();
17846 let Some((admin_pool, schema, mut client, state)) =
17847 execution_client_with_database("execution_client_test", state).await
17848 else {
17849 return;
17850 };
17851 client.config.unlimited_approval = true;
17852 client.transaction_limits.receipt_timeout_secs = 3;
17853
17854 let wrap_hash = client
17855 .wrap(U256::from(1_000_000_000_000_000u64))
17856 .await
17857 .unwrap();
17858
17859 let record = client
17860 .cache
17861 .get_execution_transaction(42161, &wrap_hash.to_string())
17862 .await
17863 .unwrap()
17864 .unwrap();
17865 assert_eq!(record.nonce, 7);
17866 assert_eq!(record.purpose, "wrap");
17867 assert_eq!(record.status, "finalized");
17868 assert_eq!(
17869 execution_intent_markers(&admin_pool, &schema).await,
17870 vec![("wrap".into(), "finalized".into(), true, false)]
17871 );
17872
17873 let approve_hash = client
17874 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
17875 .await
17876 .unwrap();
17877
17878 let record = client
17879 .cache
17880 .get_execution_transaction(42161, &approve_hash.to_string())
17881 .await
17882 .unwrap()
17883 .unwrap();
17884 assert_eq!(record.nonce, 8);
17885 assert_eq!(record.purpose, "approve");
17886 assert_eq!(record.status, "finalized");
17887 assert_eq!(
17888 execution_intent_markers(&admin_pool, &schema).await,
17889 vec![
17890 ("wrap".into(), "finalized".into(), true, false),
17891 ("approve".into(), "finalized".into(), true, false),
17892 ]
17893 );
17894
17895 let requests = state.recorded_requests();
17896 let broadcasts: Vec<_> = requests
17897 .iter()
17898 .filter(|request| request["method"] == "eth_sendRawTransaction")
17899 .collect();
17900 assert_eq!(broadcasts.len(), 2);
17901 for broadcast in &broadcasts {
17902 let payload = broadcast["params"][0].as_str().unwrap();
17903 assert!(payload.starts_with("0x02"), "was: {payload}");
17904 }
17905 let broadcast_indexes = requests
17906 .iter()
17907 .enumerate()
17908 .filter_map(|(index, request)| {
17909 (request["method"] == "eth_sendRawTransaction").then_some(index)
17910 })
17911 .collect::<Vec<_>>();
17912
17913 for (index, expected_block) in broadcast_indexes
17914 .into_iter()
17915 .zip([FIXTURE_BLOCK_PARAM, "0x1cf0d42"])
17916 {
17917 let nonce_fence = &requests[index - 6..index];
17918 assert!(nonce_fence[..3].iter().all(|request| {
17919 request["method"] == "eth_getTransactionCount"
17920 && request["params"][1] == expected_block
17921 }));
17922 assert!(nonce_fence[3..].iter().all(|request| {
17923 request["method"] == "eth_getTransactionCount" && request["params"][1] == "pending"
17924 }));
17925 }
17926 let receipt_polls = requests
17927 .iter()
17928 .filter(|request| request["method"] == "eth_getTransactionReceipt")
17929 .count();
17930 assert_eq!(receipt_polls, 6);
17931 let allowance_calls: Vec<_> = requests
17932 .iter()
17933 .filter(|request| {
17934 request["method"] == "eth_call"
17935 && request["params"][0]["data"]
17936 .as_str()
17937 .is_some_and(|data| data.starts_with(ALLOWANCE_SELECTOR))
17938 })
17939 .collect();
17940 assert_eq!(allowance_calls.len(), 6);
17941 assert!(
17942 allowance_calls[..3]
17943 .iter()
17944 .all(|request| request["params"][1] == "0x1cf0d42")
17945 );
17946 assert!(
17947 allowance_calls[3..]
17948 .iter()
17949 .all(|request| request["params"][1] == "0x1cf0d41")
17950 );
17951
17952 let estimates: Vec<_> = requests
17954 .iter()
17955 .filter(|request| request["method"] == "eth_estimateGas")
17956 .collect();
17957 assert_eq!(estimates.len(), 6);
17958 assert!(estimates[..3].iter().all(|request| {
17959 request["params"].as_array().unwrap().len() == 2
17960 && request["params"][1] == FIXTURE_BLOCK_PARAM
17961 }));
17962 assert!(estimates[3..].iter().all(|request| {
17963 request["params"].as_array().unwrap().len() == 2 && request["params"][1] == "0x1cf0d42"
17964 }));
17965 let approve_data = estimates[3]["params"][0]["data"].as_str().unwrap();
17966 assert!(
17967 approve_data.starts_with("0x095ea7b3"),
17968 "was: {approve_data}"
17969 );
17970 assert!(
17971 approve_data.ends_with(&"f".repeat(64)),
17972 "was: {approve_data}"
17973 );
17974
17975 for selector in [FACTORY_SELECTOR, WETH9_SELECTOR] {
17976 let calls: Vec<_> = requests
17977 .iter()
17978 .filter(|request| {
17979 request["method"] == "eth_call"
17980 && request["params"][0]["data"]
17981 .as_str()
17982 .is_some_and(|data| data.starts_with(selector))
17983 })
17984 .collect();
17985 assert!(!calls.is_empty(), "selector {selector}");
17986 assert!(
17987 calls.iter().all(|request| request["params"][1]
17988 .as_str()
17989 .is_some_and(|block| block.starts_with("0x"))),
17990 "selector {selector}: {calls:?}"
17991 );
17992 }
17993 let latest_blocks = requests
17994 .iter()
17995 .filter(|request| {
17996 request["method"] == "eth_getBlockByNumber"
17997 && request["params"] == serde_json::json!(["latest", false])
17998 })
17999 .count();
18000 assert_eq!(latest_blocks, 6);
18001
18002 drop_execution_schema(&admin_pool, &schema).await;
18003 }
18004
18005 #[tokio::test]
18006 async fn reverted_receipt_marks_record_reverted_and_errors() {
18007 let state = signing_rpc_state()
18008 .with_response("eth_getTransactionReceipt", RECEIPT_REVERTED)
18009 .with_send_raw_transaction_echo();
18010 let Some((admin_pool, schema, mut client, _)) =
18011 execution_client_with_database("execution_reverted_test", state).await
18012 else {
18013 return;
18014 };
18015
18016 let error = client
18017 .wrap(U256::from(1_000_000_000_000_000u64))
18018 .await
18019 .unwrap_err();
18020
18021 assert!(
18022 error.to_string().contains("reverted on-chain"),
18023 "was: {error}"
18024 );
18025 assert!(client.in_flight.lock().is_none());
18026
18027 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000u64)).await;
18028
18029 let record = client
18030 .cache
18031 .get_execution_transaction(42161, &expected_hash.to_string())
18032 .await
18033 .unwrap()
18034 .unwrap();
18035 assert_eq!(record.status, "reverted");
18036 assert_eq!(
18037 execution_intent_markers(&admin_pool, &schema).await,
18038 vec![("wrap".into(), "reverted".into(), true, false)]
18039 );
18040
18041 drop_execution_schema(&admin_pool, &schema).await;
18042 }
18043
18044 fn market_sell_order_with_id(instrument_id: InstrumentId, client_order_id: &str) -> OrderAny {
18045 OrderTestBuilder::new(OrderType::Market)
18046 .trader_id(TraderId::from("TRADER-001"))
18047 .strategy_id(StrategyId::from("S-001"))
18048 .instrument_id(instrument_id)
18049 .client_order_id(ClientOrderId::from(client_order_id))
18050 .side(OrderSide::Sell)
18051 .quantity(Quantity::from("0.001"))
18052 .build()
18053 }
18054
18055 fn market_buy_order_with_id(instrument_id: InstrumentId, client_order_id: &str) -> OrderAny {
18056 OrderTestBuilder::new(OrderType::Market)
18057 .trader_id(TraderId::from("TRADER-001"))
18058 .strategy_id(StrategyId::from("S-001"))
18059 .instrument_id(instrument_id)
18060 .client_order_id(ClientOrderId::from(client_order_id))
18061 .side(OrderSide::Buy)
18062 .quantity(Quantity::from("0.001"))
18063 .build()
18064 }
18065
18066 fn submit_order_list_cmd(orders: &[OrderAny]) -> SubmitOrderList {
18067 let order_list = OrderList::new(
18068 OrderListId::from("OL-001"),
18069 orders[0].instrument_id(),
18070 orders[0].strategy_id(),
18071 orders.iter().map(|order| order.client_order_id()).collect(),
18072 UnixNanos::default(),
18073 );
18074 SubmitOrderList::new(
18075 TraderId::from("TRADER-001"),
18076 Some(ClientId::from("BLOCKCHAIN-001")),
18077 orders[0].strategy_id(),
18078 order_list,
18079 orders
18080 .iter()
18081 .map(|order| order.init_event().clone())
18082 .collect(),
18083 None,
18084 None,
18085 None,
18086 UUID4::new(),
18087 UnixNanos::default(),
18088 None,
18089 )
18090 }
18091
18092 fn modify_order_cmd(
18093 instrument_id: InstrumentId,
18094 client_order_id: ClientOrderId,
18095 ) -> ModifyOrder {
18096 ModifyOrder::new(
18097 TraderId::from("TRADER-001"),
18098 Some(ClientId::from("BLOCKCHAIN-001")),
18099 StrategyId::from("S-001"),
18100 instrument_id,
18101 client_order_id,
18102 None,
18103 Some(Quantity::from("0.002")),
18104 None,
18105 None,
18106 UUID4::new(),
18107 UnixNanos::default(),
18108 None,
18109 None,
18110 )
18111 }
18112
18113 fn cancel_order_cmd(
18114 instrument_id: InstrumentId,
18115 client_order_id: ClientOrderId,
18116 ) -> CancelOrder {
18117 CancelOrder::new(
18118 TraderId::from("TRADER-001"),
18119 Some(ClientId::from("BLOCKCHAIN-001")),
18120 StrategyId::from("S-001"),
18121 instrument_id,
18122 client_order_id,
18123 None,
18124 UUID4::new(),
18125 UnixNanos::default(),
18126 None,
18127 None,
18128 )
18129 }
18130
18131 fn cancel_all_orders_cmd(instrument_id: InstrumentId) -> CancelAllOrders {
18132 CancelAllOrders::new(
18133 TraderId::from("TRADER-001"),
18134 Some(ClientId::from("BLOCKCHAIN-001")),
18135 StrategyId::from("S-001"),
18136 instrument_id,
18137 Some(OrderSide::Sell),
18138 UUID4::new(),
18139 UnixNanos::default(),
18140 None,
18141 None,
18142 )
18143 }
18144
18145 fn batch_cancel_orders_cmd(cancels: Vec<CancelOrder>) -> BatchCancelOrders {
18146 BatchCancelOrders::new(
18147 TraderId::from("TRADER-001"),
18148 Some(ClientId::from("BLOCKCHAIN-001")),
18149 StrategyId::from("S-001"),
18150 cancels[0].instrument_id,
18151 cancels,
18152 UUID4::new(),
18153 UnixNanos::default(),
18154 None,
18155 None,
18156 )
18157 }
18158
18159 fn query_order_cmd(instrument_id: InstrumentId, client_order_id: ClientOrderId) -> QueryOrder {
18160 QueryOrder::new(
18161 TraderId::from("TRADER-001"),
18162 Some(ClientId::from("BLOCKCHAIN-001")),
18163 StrategyId::from("S-001"),
18164 instrument_id,
18165 client_order_id,
18166 None,
18167 UUID4::new(),
18168 UnixNanos::default(),
18169 None,
18170 None,
18171 )
18172 }
18173
18174 async fn unsupported_client_with_mock_rpc()
18175 -> (BlockchainExecutionClient, MockRpcState, Rc<RefCell<Cache>>) {
18176 let state = ready_rpc_state();
18177 let addr = start_mock_rpc_server(state.clone()).await;
18178 let (client, cache) = swap_client_with_cache(test_config(format!("http://{addr}")));
18179 (client, state, cache)
18180 }
18181
18182 #[tokio::test]
18183 async fn submit_order_denies_reduce_only_without_side_effects() {
18184 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18185 let order = OrderTestBuilder::new(OrderType::Market)
18186 .trader_id(TraderId::from("TRADER-001"))
18187 .strategy_id(StrategyId::from("S-001"))
18188 .instrument_id(test_pool().instrument_id)
18189 .client_order_id(ClientOrderId::from("O-REDUCE-ONLY"))
18190 .side(OrderSide::Sell)
18191 .quantity(Quantity::from("0.001"))
18192 .reduce_only(true)
18193 .build();
18194 cache
18195 .borrow_mut()
18196 .add_order(order.clone(), None, None, false)
18197 .unwrap();
18198 let mut receiver = start_with_events(&mut client);
18199
18200 client.submit_order(submit_order_cmd(&order)).unwrap();
18201
18202 let events = collect_order_events(&mut receiver);
18203 assert_eq!(events.len(), 1, "was: {events:?}");
18204 let OrderEventAny::Denied(denied) = &events[0] else {
18205 panic!("expected OrderDenied, was {:?}", events[0]);
18206 };
18207 assert_eq!(denied.client_order_id, order.client_order_id());
18208 assert_eq!(denied.reason, "UNSUPPORTED_REDUCE_ONLY");
18209 assert!(state.recorded_requests().is_empty());
18210 assert!(client.in_flight.lock().is_none());
18211 }
18212
18213 #[tokio::test]
18214 async fn submit_order_list_denies_every_order_without_side_effects() {
18215 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18216 let pool = test_pool();
18217 let first = test_market_sell_order(pool.instrument_id);
18218 let second = market_sell_order_with_id(pool.instrument_id, "O-SWAP-002");
18219 cache
18220 .borrow_mut()
18221 .add_order(second.clone(), None, None, false)
18222 .unwrap();
18223 let orders = [first.clone(), second.clone()];
18224 let mut receiver = start_with_events(&mut client);
18225
18226 client
18227 .submit_order_list(submit_order_list_cmd(&orders))
18228 .unwrap();
18229
18230 let events = collect_order_events(&mut receiver);
18231 let mut denied_ids = Vec::new();
18232
18233 for event in &events {
18234 let OrderEventAny::Denied(denied) = event else {
18235 panic!("expected OrderDenied, was {event:?}");
18236 };
18237 assert_eq!(denied.reason, ORDER_LIST_UNSUPPORTED);
18238 denied_ids.push(denied.client_order_id);
18239 }
18240 denied_ids.sort();
18241 assert_eq!(
18242 denied_ids,
18243 [first.client_order_id(), second.client_order_id()]
18244 );
18245 assert!(state.recorded_requests().is_empty());
18246 assert!(client.in_flight.lock().is_none());
18247
18248 for order in &orders {
18249 let cache_ref = cache.borrow();
18250 let cached = cache_ref.order(&order.client_order_id()).unwrap();
18251 assert_eq!(cached.status(), OrderStatus::Initialized);
18252 }
18253 }
18254
18255 #[tokio::test]
18256 async fn modify_order_rejects_without_side_effects() {
18257 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18258 let order = test_market_sell_order(test_pool().instrument_id);
18259 let mut receiver = start_with_events(&mut client);
18260
18261 client
18262 .modify_order(modify_order_cmd(
18263 order.instrument_id(),
18264 order.client_order_id(),
18265 ))
18266 .unwrap();
18267
18268 let events = collect_order_events(&mut receiver);
18269 assert_eq!(events.len(), 1, "was: {events:?}");
18270 let OrderEventAny::ModifyRejected(rejected) = &events[0] else {
18271 panic!("expected OrderModifyRejected, was {:?}", events[0]);
18272 };
18273 assert_eq!(rejected.client_order_id, order.client_order_id());
18274 assert_eq!(rejected.reason, ORDER_MODIFY_UNSUPPORTED);
18275 assert!(state.recorded_requests().is_empty());
18276 assert!(client.in_flight.lock().is_none());
18277 let cache_ref = cache.borrow();
18278 let cached = cache_ref.order(&order.client_order_id()).unwrap();
18279 assert_eq!(cached.status(), OrderStatus::Initialized);
18280 }
18281
18282 #[tokio::test]
18283 async fn cancel_order_rejects_without_side_effects() {
18284 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18285 let order = test_market_sell_order(test_pool().instrument_id);
18286 let mut receiver = start_with_events(&mut client);
18287
18288 client
18289 .cancel_order(cancel_order_cmd(
18290 order.instrument_id(),
18291 order.client_order_id(),
18292 ))
18293 .unwrap();
18294
18295 let events = collect_order_events(&mut receiver);
18296 assert_eq!(events.len(), 1, "was: {events:?}");
18297 let OrderEventAny::CancelRejected(rejected) = &events[0] else {
18298 panic!("expected OrderCancelRejected, was {:?}", events[0]);
18299 };
18300 assert_eq!(rejected.client_order_id, order.client_order_id());
18301 assert_eq!(rejected.reason, ORDER_CANCEL_UNSUPPORTED);
18302 assert!(state.recorded_requests().is_empty());
18303 assert!(client.in_flight.lock().is_none());
18304 let cache_ref = cache.borrow();
18305 let cached = cache_ref.order(&order.client_order_id()).unwrap();
18306 assert_eq!(cached.status(), OrderStatus::Initialized);
18307 }
18308
18309 #[tokio::test]
18310 async fn batch_cancel_orders_rejects_each_order_without_side_effects() {
18311 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18312 let pool = test_pool();
18313 let first = test_market_sell_order(pool.instrument_id);
18314 let second = market_sell_order_with_id(pool.instrument_id, "O-SWAP-002");
18315 cache
18316 .borrow_mut()
18317 .add_order(second.clone(), None, None, false)
18318 .unwrap();
18319 let orders = [first.clone(), second.clone()];
18320 let cancels = orders
18321 .iter()
18322 .map(|order| cancel_order_cmd(order.instrument_id(), order.client_order_id()))
18323 .collect();
18324 let mut receiver = start_with_events(&mut client);
18325
18326 client
18327 .batch_cancel_orders(batch_cancel_orders_cmd(cancels))
18328 .unwrap();
18329
18330 let events = collect_order_events(&mut receiver);
18331 let mut rejected_ids = Vec::new();
18332
18333 for event in &events {
18334 let OrderEventAny::CancelRejected(rejected) = event else {
18335 panic!("expected OrderCancelRejected, was {event:?}");
18336 };
18337 assert_eq!(rejected.reason, ORDER_CANCEL_UNSUPPORTED);
18338 rejected_ids.push(rejected.client_order_id);
18339 }
18340 rejected_ids.sort();
18341 assert_eq!(
18342 rejected_ids,
18343 [first.client_order_id(), second.client_order_id()]
18344 );
18345 assert!(state.recorded_requests().is_empty());
18346 assert!(client.in_flight.lock().is_none());
18347 }
18348
18349 #[tokio::test]
18350 async fn cancel_all_and_query_order_log_unsupported_without_side_effects() {
18351 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18352 let order = test_market_sell_order(test_pool().instrument_id);
18353 let mut receiver = start_with_events(&mut client);
18354
18355 client
18356 .cancel_all_orders(cancel_all_orders_cmd(order.instrument_id()))
18357 .unwrap();
18358 client
18359 .query_order(query_order_cmd(
18360 order.instrument_id(),
18361 order.client_order_id(),
18362 ))
18363 .unwrap();
18364
18365 let events = collect_order_events(&mut receiver);
18366 assert!(events.is_empty(), "was: {events:?}");
18367 assert!(state.recorded_requests().is_empty());
18368 assert!(client.in_flight.lock().is_none());
18369 let cache_ref = cache.borrow();
18370 let cached = cache_ref.order(&order.client_order_id()).unwrap();
18371 assert_eq!(cached.status(), OrderStatus::Initialized);
18372 }
18373
18374 #[tokio::test]
18375 async fn unsupported_commands_handle_unknown_orders_without_panic() {
18376 let (mut client, state, _) = unsupported_client_with_mock_rpc().await;
18377 let instrument_id = test_pool().instrument_id;
18378 let unknown = ClientOrderId::from("O-UNKNOWN");
18379 let mut receiver = start_with_events(&mut client);
18380
18381 client
18382 .modify_order(modify_order_cmd(instrument_id, unknown))
18383 .unwrap();
18384 client
18385 .cancel_order(cancel_order_cmd(instrument_id, unknown))
18386 .unwrap();
18387 client
18388 .batch_cancel_orders(batch_cancel_orders_cmd(vec![cancel_order_cmd(
18389 instrument_id,
18390 unknown,
18391 )]))
18392 .unwrap();
18393 client
18394 .query_order(query_order_cmd(instrument_id, unknown))
18395 .unwrap();
18396
18397 let events = collect_order_events(&mut receiver);
18398 assert!(events.is_empty(), "was: {events:?}");
18399 assert!(state.recorded_requests().is_empty());
18400 assert!(client.in_flight.lock().is_none());
18401 }
18402
18403 #[tokio::test]
18404 async fn report_generators_error_except_mass_status_without_side_effects() {
18405 let (client, state, _) = unsupported_client_with_mock_rpc().await;
18406
18407 let report = client
18408 .generate_order_status_report(&GenerateOrderStatusReport::new(
18409 UUID4::new(),
18410 UnixNanos::default(),
18411 None,
18412 None,
18413 None,
18414 None,
18415 None,
18416 ))
18417 .await
18418 .unwrap_err();
18419 assert_eq!(report.to_string(), VENUE_EXECUTION_REPORTS_UNSUPPORTED);
18420
18421 let reports = client
18422 .generate_order_status_reports(&GenerateOrderStatusReports::new(
18423 UUID4::new(),
18424 UnixNanos::default(),
18425 false,
18426 None,
18427 None,
18428 None,
18429 None,
18430 None,
18431 ))
18432 .await
18433 .unwrap_err();
18434 assert_eq!(reports.to_string(), VENUE_EXECUTION_REPORTS_UNSUPPORTED);
18435
18436 let fills = client
18437 .generate_fill_reports(GenerateFillReports::new(
18438 UUID4::new(),
18439 UnixNanos::default(),
18440 None,
18441 None,
18442 None,
18443 None,
18444 None,
18445 None,
18446 ))
18447 .await
18448 .unwrap_err();
18449 assert_eq!(fills.to_string(), VENUE_EXECUTION_REPORTS_UNSUPPORTED);
18450
18451 let positions = client
18452 .generate_position_status_reports(&GeneratePositionStatusReports::new(
18453 UUID4::new(),
18454 UnixNanos::default(),
18455 None,
18456 None,
18457 None,
18458 None,
18459 None,
18460 ))
18461 .await
18462 .unwrap_err();
18463 assert_eq!(positions.to_string(), VENUE_EXECUTION_REPORTS_UNSUPPORTED);
18464
18465 let mass_status = client.generate_mass_status(None).await.unwrap();
18466 assert!(mass_status.is_none());
18467
18468 let mass_status = client.generate_mass_status(Some(60)).await.unwrap();
18469 assert!(mass_status.is_none());
18470
18471 assert!(state.recorded_requests().is_empty());
18472 assert!(client.in_flight.lock().is_none());
18473 }
18474
18475 async fn connect_test_postgres(
18476 test_name: &str,
18477 ) -> Option<(sqlx::PgPool, PostgresConnectOptions)> {
18478 let pg_config = get_postgres_connect_options(None, None, None, None, None);
18479 let admin_options: sqlx::postgres::PgConnectOptions = pg_config.clone().into();
18480 let admin_pool = match PgPoolOptions::new()
18481 .max_connections(1)
18482 .connect_with(admin_options)
18483 .await
18484 {
18485 Ok(pool) => pool,
18486 Err(e) => {
18487 eprintln!("Postgres unavailable; skipping {test_name} test: {e}");
18488 return None;
18489 }
18490 };
18491
18492 Some((admin_pool, pg_config))
18493 }
18494
18495 async fn execution_client_with_database(
18496 test_name: &str,
18497 state: MockRpcState,
18498 ) -> Option<(
18499 sqlx::PgPool,
18500 String,
18501 BlockchainExecutionClient,
18502 MockRpcState,
18503 )> {
18504 let (admin_pool, schema, mut client, state) =
18505 execution_client_with_unprotected_database(test_name, state).await?;
18506 protect_test_storage(&mut client, &schema).await;
18507 Some((admin_pool, schema, client, state))
18508 }
18509
18510 async fn execution_client_with_unprotected_database(
18511 test_name: &str,
18512 state: MockRpcState,
18513 ) -> Option<(
18514 sqlx::PgPool,
18515 String,
18516 BlockchainExecutionClient,
18517 MockRpcState,
18518 )> {
18519 let (admin_pool, pg_config) = connect_test_postgres(test_name).await?;
18520 let schema = format!("{test_name}_{}", std::process::id());
18521 setup_execution_schema(&admin_pool, &schema).await;
18522
18523 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
18524 let db_options = db_options.options([("search_path", schema.clone())]);
18525 let database = connect_test_database(db_options).await.unwrap();
18526 let addr = start_mock_rpc_server(state.clone()).await;
18527 let mut client = test_client(format!("http://{addr}"));
18528 client.cache.database = Some(database);
18529 client
18531 .cache
18532 .ensure_execution_transaction_schema()
18533 .await
18534 .unwrap();
18535 initialize_test_verification_ledger(&client).await;
18536 client.signer = Some(Arc::new(
18537 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
18538 ));
18539 client.core.set_connected();
18540
18541 Some((admin_pool, schema, client, state))
18542 }
18543
18544 async fn install_reservation_commit_rejection(admin_pool: &sqlx::PgPool, schema: &str) {
18545 for statement in [
18546 format!(
18547 "CREATE FUNCTION {schema}.reject_execution_reservation_commit() RETURNS trigger \
18548 LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''test reservation commit rejection''; \
18549 RETURN NEW; END'"
18550 ),
18551 format!(
18552 "CREATE CONSTRAINT TRIGGER reject_execution_reservation_commit AFTER INSERT ON \
18553 {schema}.execution_transaction_transition DEFERRABLE INITIALLY DEFERRED \
18554 FOR EACH ROW EXECUTE FUNCTION {schema}.reject_execution_reservation_commit()"
18555 ),
18556 ] {
18557 sqlx::query(sqlx::AssertSqlSafe(statement))
18558 .execute(admin_pool)
18559 .await
18560 .unwrap();
18561 }
18562 }
18563
18564 async fn install_recoverable_commit_rejection(admin_pool: &sqlx::PgPool, schema: &str) {
18565 for statement in [
18566 format!(
18567 "CREATE FUNCTION {schema}.reject_recoverable_commit() RETURNS trigger \
18568 LANGUAGE plpgsql AS 'BEGIN IF NEW.transition_key = ''recoverable'' THEN \
18569 RAISE EXCEPTION ''test recoverable commit rejection''; END IF; \
18570 RETURN NEW; END'"
18571 ),
18572 format!(
18573 "CREATE CONSTRAINT TRIGGER reject_recoverable_commit AFTER INSERT ON \
18574 {schema}.execution_transaction_transition DEFERRABLE INITIALLY DEFERRED \
18575 FOR EACH ROW EXECUTE FUNCTION {schema}.reject_recoverable_commit()"
18576 ),
18577 ] {
18578 sqlx::query(sqlx::AssertSqlSafe(statement))
18579 .execute(admin_pool)
18580 .await
18581 .unwrap();
18582 }
18583 }
18584
18585 async fn drop_execution_schema(admin_pool: &sqlx::PgPool, schema: &str) {
18586 sqlx::query(sqlx::AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE")))
18587 .execute(admin_pool)
18588 .await
18589 .unwrap();
18590 }
18591
18592 async fn setup_execution_schema(admin_pool: &sqlx::PgPool, schema: &str) {
18593 for statement in [
18594 format!("CREATE SCHEMA {schema}"),
18595 format!(
18596 r#"CREATE TABLE {schema}."chain" (chain_id INTEGER PRIMARY KEY, name TEXT NOT NULL)"#
18597 ),
18598 format!(r#"INSERT INTO {schema}."chain" (chain_id, name) VALUES (42161, 'Arbitrum')"#),
18599 format!(
18600 r#"CREATE TABLE {schema}."execution_transaction" (
18601 id BIGSERIAL PRIMARY KEY,
18602 chain_id INTEGER NOT NULL REFERENCES {schema}."chain"(chain_id) ON DELETE CASCADE,
18603 nonce BIGINT NOT NULL,
18604 transaction_hash TEXT NOT NULL,
18605 purpose TEXT NOT NULL,
18606 status TEXT NOT NULL,
18607 UNIQUE (chain_id, transaction_hash)
18608 )"#
18609 ),
18610 ] {
18611 sqlx::query(sqlx::AssertSqlSafe(statement))
18612 .execute(admin_pool)
18613 .await
18614 .unwrap();
18615 }
18616 }
18617
18618 fn execution_transaction_create_sql() -> &'static str {
18619 const TABLES_SQL: &str = include_str!("../../../../../schema/sql/tables.sql");
18620 const START: &str = "CREATE TABLE IF NOT EXISTS \"execution_transaction\"";
18621 let start = TABLES_SQL
18622 .find(START)
18623 .expect("execution_transaction table is missing from tables.sql");
18624 let statement = &TABLES_SQL[start..];
18625 let end = statement
18626 .find(";\n")
18627 .expect("execution_transaction CREATE TABLE is unterminated")
18628 + 1;
18629 &statement[..end]
18630 }
18631}