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, 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 helpers as rpc_helpers,
112 http::{BlockchainHttpRpcClient, EXECUTION_RPC_TIMEOUT_SECS},
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);
123const MAX_PAYLOAD_OPERATION_BATCH_SIZE: usize = 1_000;
124const BPS_DENOMINATOR: u32 = 10_000;
126const ORDER_LIST_UNSUPPORTED: &str =
128 "Order lists are not supported; submit each order individually";
129const ORDER_MODIFY_UNSUPPORTED: &str = "Order modification is not supported";
131const ORDER_CANCEL_UNSUPPORTED: &str = "Order cancellation is not supported";
133const VENUE_EXECUTION_REPORTS_UNSUPPORTED: &str =
135 "Venue execution reports are not supported on the blockchain execution client";
136const MAX_REPLACEMENT_SCAN_BLOCKS: u64 = 4_096;
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct PayloadStorageCheck {
142 pub protected: bool,
144 pub deployment_id: Option<String>,
146 pub plaintext_rows: u64,
148 pub original_rows: u64,
150 pub replacement_rows: u64,
152 pub authenticated_rows: u64,
154 pub key_ids: Vec<String>,
156 pub read_roles: Vec<String>,
158}
159
160impl From<ExecutionPayloadCheck> for PayloadStorageCheck {
161 fn from(value: ExecutionPayloadCheck) -> Self {
162 Self {
163 protected: value.protected,
164 deployment_id: value.deployment_id,
165 plaintext_rows: value.plaintext_rows,
166 original_rows: value.original_rows,
167 replacement_rows: value.replacement_rows,
168 authenticated_rows: value.authenticated_rows,
169 key_ids: value.key_ids,
170 read_roles: value.read_roles,
171 }
172 }
173}
174
175#[derive(Debug, Clone, Copy)]
177struct InFlightTransaction {
178 intent_id: i64,
179 nonce: u64,
180 tx_hash: B256,
181 purpose: TransactionPurpose,
182}
183
184#[derive(Debug, Clone, Copy)]
185struct RecoveryTransaction {
186 intent_id: i64,
187 nonce: u64,
188 purpose: TransactionPurpose,
189}
190
191#[derive(Debug, Clone, Copy)]
199enum InFlightSlot {
200 Preparing(TransactionPurpose),
202 Recovering(RecoveryTransaction),
204 AwaitingFinality(InFlightTransaction),
206}
207
208#[derive(Debug, Clone)]
209struct IncludedTransaction {
210 intent_id: i64,
211 nonce: u64,
212 tx_hash: B256,
213 block_number: u64,
214 receipt: RpcTransactionReceipt,
215 finality: StableFinality,
216}
217
218#[derive(Debug, Clone)]
219struct StableFinality {
220 decisions: Vec<ExecutionVerificationDecision>,
221 inclusion_header: ExecutionVerifiedHeader,
222 finalized_headers: Vec<ExecutionVerifiedHeader>,
223}
224
225#[derive(Debug, Clone, Copy)]
226enum TransactionAuthorization {
227 Wrap {
228 weth: Address,
229 },
230 Approve {
231 token: Address,
232 router: Address,
233 amount: U256,
234 },
235}
236
237fn in_flight_limit_error(slot: &InFlightSlot) -> anyhow::Error {
239 match slot {
240 InFlightSlot::Preparing(purpose) => anyhow::anyhow!(
241 "A {} transaction is being prepared; at most one transaction can be in flight",
242 purpose.as_str()
243 ),
244 InFlightSlot::Recovering(recovery) => anyhow::anyhow!(
245 "Execution intent {} ({}, nonce {}) retains signer ownership pending recovery; at most one transaction can be in flight",
246 recovery.intent_id,
247 recovery.purpose.as_str(),
248 recovery.nonce
249 ),
250 InFlightSlot::AwaitingFinality(in_flight) => anyhow::anyhow!(
251 "Transaction {} (intent {}, {}, nonce {}) is still awaiting finality; at most one transaction can be in flight",
252 in_flight.tx_hash,
253 in_flight.intent_id,
254 in_flight.purpose.as_str(),
255 in_flight.nonce
256 ),
257 }
258}
259
260fn release_preparing_slot(in_flight: &Mutex<Option<InFlightSlot>>) {
265 let mut slot = in_flight.lock();
266 if matches!(*slot, Some(InFlightSlot::Preparing(_))) {
267 *slot = None;
268 }
269}
270
271fn release_preparing_if_reservation_not_committed(
272 in_flight: &Mutex<Option<InFlightSlot>>,
273 error: &anyhow::Error,
274) {
275 if reservation_failure_proven_not_committed(error) {
276 release_preparing_slot(in_flight);
277 }
278}
279
280#[derive(Debug)]
281struct TransactionLimits {
282 allowed_token_pairs: HashSet<(Address, Address)>,
283 quote_spend_limits: HashMap<(Address, Address), QuoteSpendCeiling>,
284 slippage_bps: u32,
285 max_slippage_bps: u32,
286 max_order_amount: u64,
287 deadline_seconds: u64,
288 max_quote_age_blocks: u64,
289 receipt_timeout_secs: u64,
290}
291
292#[derive(Debug, Clone, Copy)]
293struct QuoteSpendCeiling {
294 spend_token: Address,
295 spend_token_decimals: u8,
296 max_amount: U256,
297}
298
299#[derive(Debug)]
301pub struct BlockchainExecutionClient {
302 core: ExecutionClientCore,
303 emitter: ExecutionEventEmitter,
304 cache: BlockchainCache,
305 config: BlockchainExecutionClientConfig,
306 chain: SharedChain,
307 wallet_address: Address,
308 signer: Option<Arc<PrivateKeySigner>>,
309 payload_keys: Option<Arc<PayloadKeySet>>,
310 router_addresses: Vec<Address>,
311 transaction_limits: TransactionLimits,
312 weth_address: Address,
313 in_flight: Arc<Mutex<Option<InFlightSlot>>>,
314 wallet_balance: Arc<Mutex<WalletBalance>>,
315 erc20_contract: Erc20Contract,
316 http_rpc_client: Arc<BlockchainHttpRpcClient>,
317 verification: VerificationCoordinator,
318 pending_tasks: TaskGroup,
319}
320
321impl BlockchainExecutionClient {
322 pub fn new(
331 core_client: ExecutionClientCore,
332 config: BlockchainExecutionClientConfig,
333 ) -> anyhow::Result<Self> {
334 let transaction_limits = Self::transaction_limits(&config)?;
335 let chain = Arc::new(config.chain.clone());
336 let cache = BlockchainCache::new(chain.clone());
337 let http_rpc_client = Arc::new(BlockchainHttpRpcClient::new(
338 config.http_rpc_url.clone(),
339 config.rpc_requests_per_second,
340 None,
341 ));
342 let verification_config = config.verification.as_ref().ok_or_else(|| {
343 anyhow::anyhow!("Independent Blockchain execution verification is required")
344 })?;
345 anyhow::ensure!(
346 verification_config.chain_anchor.chain_id == config.chain.chain_id,
347 "Verification chain anchor ID does not match the configured chain"
348 );
349 anyhow::ensure!(
350 verification_config.chain_anchor.chain_name == config.chain.name.to_string(),
351 "Verification chain anchor name does not match the configured chain"
352 );
353 let verification = VerificationCoordinator::new(
354 http_rpc_client.clone(),
355 &config.http_rpc_url,
356 verification_config,
357 config.rpc_requests_per_second,
358 )?;
359 let wallet_address = validate_address(config.wallet_address.as_str())?;
360 let erc20_contract = Erc20Contract::new_with_timeout(
361 http_rpc_client.clone(),
362 Some(EXECUTION_RPC_TIMEOUT_SECS),
363 true,
364 );
365
366 let router_addresses = config
367 .router_addresses
368 .iter()
369 .map(|address| validate_address(address.as_str()))
370 .collect::<anyhow::Result<Vec<_>>>()?;
371 if router_addresses.is_empty() {
372 anyhow::bail!("`router_addresses` must contain at least one router address");
373 }
374 let weth_address = validate_address(config.weth_address.as_str())?;
375 Self::validate_manifest_contracts(&config, &router_addresses, weth_address)?;
376
377 let mut token_universe = HashSet::new();
379
380 if let Some(specified_tokens) = &config.tokens {
381 for token in specified_tokens {
382 let token_address = validate_address(token.as_str())?;
383 token_universe.insert(token_address);
384 }
385 }
386 let wallet_balance = WalletBalance::new(token_universe);
387 let emitter = ExecutionEventEmitter::new(
388 get_atomic_clock_realtime(),
389 core_client.trader_id,
390 core_client.account_id,
391 core_client.account_type,
392 core_client.base_currency,
393 );
394
395 let pending_tasks = TaskGroup::new();
396
397 Ok(Self {
398 core: core_client,
399 emitter,
400 wallet_balance: Arc::new(Mutex::new(wallet_balance)),
401 chain,
402 cache,
403 config,
404 signer: None,
405 payload_keys: None,
406 router_addresses,
407 transaction_limits,
408 weth_address,
409 in_flight: Arc::new(Mutex::new(None)),
410 erc20_contract,
411 http_rpc_client,
412 verification,
413 wallet_address,
414 pending_tasks,
415 })
416 }
417
418 fn transaction_limits(
419 config: &BlockchainExecutionClientConfig,
420 ) -> anyhow::Result<TransactionLimits> {
421 let (
422 Some(allowed_token_pairs),
423 Some(slippage_bps),
424 Some(max_slippage_bps),
425 Some(max_order_amount),
426 Some(deadline_seconds),
427 Some(max_quote_age_blocks),
428 Some(receipt_timeout_secs),
429 ) = (
430 &config.allowed_token_pairs,
431 config.slippage_bps,
432 config.max_slippage_bps,
433 config.max_order_amount,
434 config.deadline_seconds,
435 config.max_quote_age_blocks,
436 config.receipt_timeout_secs,
437 )
438 else {
439 anyhow::bail!(
440 "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"
441 );
442 };
443
444 let mut parsed_pairs = HashSet::with_capacity(allowed_token_pairs.len());
445 for (token_in, token_out) in allowed_token_pairs {
446 parsed_pairs.insert((
447 validate_address(token_in.as_str())?,
448 validate_address(token_out.as_str())?,
449 ));
450 }
451
452 let quote_spend_limits = config.quote_spend_limits.as_deref().unwrap_or_default();
453 let mut parsed_quote_spend_limits = HashMap::with_capacity(quote_spend_limits.len());
454 for limit in quote_spend_limits {
455 let token_in = validate_address(limit.token_in.as_str())?;
456 let token_out = validate_address(limit.token_out.as_str())?;
457 let spend_token = validate_address(limit.spend_token.as_str())?;
458
459 if !parsed_pairs.contains(&(token_in, token_out)) {
460 anyhow::bail!(
461 "Quote spend limit pair {token_in} -> {token_out} is not in the `allowed_token_pairs` allowlist"
462 );
463 }
464
465 if spend_token != token_in {
466 anyhow::bail!(
467 "Quote spend limit for {token_in} -> {token_out} is denominated in {spend_token}; `spend_token` must match `token_in`"
468 );
469 }
470
471 if limit.max_amount.is_empty()
472 || !limit.max_amount.bytes().all(|byte| byte.is_ascii_digit())
473 {
474 anyhow::bail!(
475 "Quote spend limit `max_amount` '{}' must be a base-10 unsigned integer string",
476 limit.max_amount
477 );
478 }
479 let max_amount = U256::from_str(&limit.max_amount).map_err(|_| {
480 anyhow::anyhow!(
481 "Quote spend limit `max_amount` '{}' exceeds the U256 range",
482 limit.max_amount
483 )
484 })?;
485 let ceiling = QuoteSpendCeiling {
486 spend_token,
487 spend_token_decimals: limit.spend_token_decimals,
488 max_amount,
489 };
490
491 if parsed_quote_spend_limits
492 .insert((token_in, token_out), ceiling)
493 .is_some()
494 {
495 anyhow::bail!(
496 "Duplicate quote spend limit for token pair {token_in} -> {token_out}"
497 );
498 }
499 }
500
501 if slippage_bps > max_slippage_bps {
502 anyhow::bail!(
503 "`slippage_bps` {slippage_bps} exceeds `max_slippage_bps` {max_slippage_bps}"
504 );
505 }
506
507 if max_slippage_bps >= BPS_DENOMINATOR {
508 anyhow::bail!("`max_slippage_bps` {max_slippage_bps} must be below {BPS_DENOMINATOR}");
509 }
510
511 if !(1..=4_095).contains(&max_quote_age_blocks) {
512 anyhow::bail!("`max_quote_age_blocks` must be in 1..=4095");
513 }
514
515 Ok(TransactionLimits {
516 allowed_token_pairs: parsed_pairs,
517 quote_spend_limits: parsed_quote_spend_limits,
518 slippage_bps,
519 max_slippage_bps,
520 max_order_amount,
521 deadline_seconds,
522 max_quote_age_blocks,
523 receipt_timeout_secs,
524 })
525 }
526
527 fn validate_manifest_contracts(
528 config: &BlockchainExecutionClientConfig,
529 routers: &[Address],
530 weth: Address,
531 ) -> anyhow::Result<()> {
532 let verification = config.verification.as_ref().ok_or_else(|| {
533 anyhow::anyhow!("Independent Blockchain execution verification is required")
534 })?;
535 let manifest = &verification.deployment_manifest;
536 let role_addresses = |role| {
537 manifest
538 .contracts
539 .iter()
540 .filter(|contract| contract.role == role)
541 .map(|contract| {
542 Address::from_str(&contract.address)
543 .map_err(|_| anyhow::anyhow!("Deployment manifest address is invalid"))
544 })
545 .collect::<anyhow::Result<HashSet<_>>>()
546 };
547 let singleton = |role, description: &str| {
548 let addresses = role_addresses(role)?;
549 anyhow::ensure!(
550 addresses.len() == 1,
551 "Deployment manifest must contain exactly one {description} contract"
552 );
553 Ok(*addresses.iter().next().expect("singleton role address"))
554 };
555
556 let configured_routers = routers.iter().copied().collect::<HashSet<_>>();
557 anyhow::ensure!(
558 role_addresses(BlockchainContractRole::Router)? == configured_routers,
559 "Deployment manifest router set does not match `router_addresses`"
560 );
561 anyhow::ensure!(
562 singleton(BlockchainContractRole::WrappedNative, "wrapped native")? == weth,
563 "Deployment manifest wrapped native contract does not match `weth_address`"
564 );
565 let factory = singleton(BlockchainContractRole::Factory, "factory")?;
566 let registered_factory =
567 crate::exchanges::get_dex_extended(config.chain.name, &DexType::UniswapV3)
568 .map(|dex| dex.factory)
569 .ok_or_else(|| {
570 anyhow::anyhow!(
571 "No registered Uniswap V3 deployment for chain {}",
572 config.chain.name
573 )
574 })?;
575 anyhow::ensure!(
576 factory == registered_factory,
577 "Deployment manifest factory does not match the registered Uniswap V3 factory"
578 );
579 let quote_contract = singleton(BlockchainContractRole::Quote, "quote")?;
580
581 let mut token_decimals = HashMap::new();
582
583 for token in &manifest.tokens {
584 let address = Address::from_str(&token.address)
585 .map_err(|_| anyhow::anyhow!("Deployment manifest token address is invalid"))?;
586 anyhow::ensure!(
587 token_decimals.insert(address, token.decimals).is_none(),
588 "Deployment manifest contains a duplicate token identity"
589 );
590 }
591 anyhow::ensure!(
592 token_decimals.contains_key(&weth),
593 "Deployment manifest has no wrapped native token identity"
594 );
595
596 if let Some(tokens) = &config.tokens {
597 for token in tokens {
598 let address = validate_address(token)?;
599 anyhow::ensure!(
600 token_decimals.contains_key(&address),
601 "Configured token {address} has no deployment manifest identity"
602 );
603 }
604 }
605
606 for (token_in, token_out) in config.allowed_token_pairs.as_deref().unwrap_or_default() {
607 let token_in = validate_address(token_in)?;
608 let token_out = validate_address(token_out)?;
609 anyhow::ensure!(
610 token_in != token_out
611 && token_decimals.contains_key(&token_in)
612 && token_decimals.contains_key(&token_out),
613 "Allowed token pair {token_in} -> {token_out} is not fully pinned by the deployment manifest"
614 );
615 }
616
617 for limit in config.quote_spend_limits.as_deref().unwrap_or_default() {
618 let spend_token = validate_address(&limit.spend_token)?;
619 anyhow::ensure!(
620 token_decimals.get(&spend_token) == Some(&limit.spend_token_decimals),
621 "Quote spend limit decimals do not match the deployment manifest"
622 );
623 }
624
625 let pool_contracts = role_addresses(BlockchainContractRole::Pool)?;
626
627 for pool in &manifest.pools {
628 let pool_address = Address::from_str(&pool.address)
629 .map_err(|_| anyhow::anyhow!("Deployment manifest pool address is invalid"))?;
630 let pool_factory = Address::from_str(&pool.factory)
631 .map_err(|_| anyhow::anyhow!("Deployment manifest pool factory is invalid"))?;
632 let pool_quote = Address::from_str(&pool.quote_contract).map_err(|_| {
633 anyhow::anyhow!("Deployment manifest pool quote contract is invalid")
634 })?;
635 anyhow::ensure!(
636 pool_contracts.contains(&pool_address)
637 && pool_factory == factory
638 && pool_quote == quote_contract,
639 "Deployment manifest pool does not use the pinned pool, factory, and quote identities"
640 );
641 }
642 Ok(())
643 }
644
645 async fn fetch_native_currency_balance(&self) -> anyhow::Result<Money> {
647 let balance_u256 = self
648 .http_rpc_client
649 .get_balance_with_timeout(&self.wallet_address, None, Some(EXECUTION_RPC_TIMEOUT_SECS))
650 .await?;
651
652 let native_currency = self.chain.native_currency();
653
654 Money::from_u256(balance_u256, native_currency).map_err(Into::into)
655 }
656
657 async fn fetch_token_balance(
659 &mut self,
660 token_address: &Address,
661 ) -> anyhow::Result<TokenBalance> {
662 let token = if let Some(token) = self.cache.get_token(token_address) {
664 token.to_owned()
665 } else {
666 let token_info = self.erc20_contract.fetch_token_info(token_address).await?;
667 let token = Token::new(
668 self.chain.clone(),
669 *token_address,
670 token_info.name,
671 token_info.symbol,
672 token_info.decimals,
673 );
674 self.cache.add_token(token.clone()).await?;
675 token
676 };
677
678 let amount = self
679 .erc20_contract
680 .balance_of(token_address, &self.wallet_address)
681 .await?;
682 let token_balance = TokenBalance::new(amount, token);
683
684 Ok(token_balance)
688 }
689
690 async fn refresh_wallet_balances(&mut self) -> anyhow::Result<()> {
692 let (wallet_balance, balances) = self.fetch_wallet_balances().await?;
693 self.generate_account_state(
694 balances,
695 vec![],
696 true,
697 get_atomic_clock_realtime().get_time_ns(),
698 None,
699 )?;
700 *self.wallet_balance.lock() = wallet_balance;
701 Ok(())
702 }
703
704 async fn fetch_wallet_balances(
705 &mut self,
706 ) -> anyhow::Result<(WalletBalance, Vec<AccountBalance>)> {
707 let native_currency_balance = self.fetch_native_currency_balance().await?;
708 let token_universe = self.wallet_balance.lock().token_universe.clone();
709 let mut token_addresses = token_universe.iter().copied().collect::<Vec<_>>();
710 token_addresses.sort_unstable();
711
712 let mut token_balances = Vec::with_capacity(token_addresses.len());
713 for token_address in token_addresses {
714 let token_balance = self
715 .fetch_token_balance(&token_address)
716 .await
717 .with_context(|| format!("failed to fetch token balance for {token_address}"))?;
718 token_balances.push(token_balance);
719 }
720
721 let mut wallet_balance = WalletBalance::new(token_universe);
722 let balances = wallet_balance.replace_balances(native_currency_balance, token_balances)?;
723 log::debug!(
724 "Refreshed wallet balance with {} account balances",
725 balances.len()
726 );
727 Ok((wallet_balance, balances))
728 }
729
730 pub async fn preflight(
742 &self,
743 instrument_id: &InstrumentId,
744 ) -> anyhow::Result<BlockchainPreflightReport> {
745 let pool = self.resolve_pool(instrument_id)?;
746 let base_token = pool.get_base_token().clone();
747 let quote_token = pool.get_quote_token().clone();
748
749 let actual_chain_id = self.http_rpc_client.chain_id().await?;
750
751 let pool_code = self.http_rpc_client.get_code(&pool.address).await?;
752 let pool_check = PoolPreflightCheck {
753 instrument_id: pool.instrument_id,
754 address: pool.address,
755 has_deployed_code: !pool_code.is_empty(),
756 fee: pool.fee,
757 base_token: base_token.address,
758 quote_token: quote_token.address,
759 };
760
761 let mut routers = Vec::with_capacity(self.router_addresses.len());
762 for router in &self.router_addresses {
763 let code = self.http_rpc_client.get_code(router).await?;
764 routers.push(ContractCodeCheck {
765 address: *router,
766 has_deployed_code: !code.is_empty(),
767 });
768 }
769
770 let mut tokens = Vec::with_capacity(2);
771
772 for token in [&base_token, "e_token] {
773 let code = self.http_rpc_client.get_code(&token.address).await?;
774 let wallet_balance = self
775 .erc20_contract
776 .balance_of(&token.address, &self.wallet_address)
777 .await?;
778
779 let mut router_allowances = Vec::new();
780 if token.address == base_token.address {
781 router_allowances.reserve(self.router_addresses.len());
782 for router in &self.router_addresses {
783 let amount = self
784 .erc20_contract
785 .allowance(&token.address, &self.wallet_address, router)
786 .await?;
787 router_allowances.push((*router, amount));
788 }
789 }
790
791 tokens.push(TokenPreflightCheck {
792 address: token.address,
793 symbol: token.symbol.clone(),
794 has_deployed_code: !code.is_empty(),
795 wallet_balance,
796 router_allowances,
797 });
798 }
799
800 let native_balance_wei = self
801 .http_rpc_client
802 .get_balance_with_timeout(&self.wallet_address, None, Some(EXECUTION_RPC_TIMEOUT_SECS))
803 .await?;
804
805 let latest_block = self.http_rpc_client.latest_block().await?;
806 let base_fee_per_gas_wei = latest_block.base_fee_per_gas.ok_or_else(|| {
807 anyhow::anyhow!("Latest block {} has no base fee", latest_block.number)
808 })?;
809 let max_priority_fee_per_gas_wei = self.http_rpc_client.max_priority_fee_per_gas().await?;
810 let derived_max_fee_per_gas_wei = compute_max_fee(
811 base_fee_per_gas_wei,
812 max_priority_fee_per_gas_wei,
813 self.config.base_fee_buffer_bps,
814 )?;
815
816 Ok(BlockchainPreflightReport::new(
817 u64::from(self.chain.chain_id),
818 actual_chain_id,
819 pool_check,
820 routers,
821 tokens,
822 native_balance_wei,
823 base_fee_per_gas_wei,
824 max_priority_fee_per_gas_wei,
825 derived_max_fee_per_gas_wei,
826 u128::from(self.config.max_fee_per_gas_wei),
827 ))
828 }
829
830 pub async fn wrap(&mut self, amount_wei: U256) -> anyhow::Result<B256> {
843 if amount_wei.is_zero() {
844 anyhow::bail!("Wrap amount must be positive");
845 }
846
847 self.ensure_transaction_ready(TransactionPurpose::Wrap)?;
848
849 let calldata = WETH9::depositCall {}.abi_encode();
850 let executor = self.transaction_executor()?;
851 let included = executor
852 .transact(
853 self.weth_address,
854 amount_wei,
855 Bytes::from(calldata),
856 TransactionPurpose::Wrap,
857 None,
858 TransactionAuthorization::Wrap {
859 weth: self.weth_address,
860 },
861 )
862 .await?;
863 let postconditions =
864 verify_wrap_balance_increase(&executor, &self.weth_address, amount_wei, &included)
865 .await?;
866 executor
867 .commit_verified_finality(&included, TransactionStatus::Finalized, &postconditions)
868 .await?;
869 executor
870 .database
871 .mark_execution_event_emitted(included.intent_id, "terminal")
872 .await?;
873 executor.release_slot();
874
875 Ok(included.tx_hash)
876 }
877
878 pub async fn approve(
893 &mut self,
894 token: Address,
895 amount: U256,
896 router: Address,
897 ) -> anyhow::Result<B256> {
898 if !self.router_addresses.contains(&router) {
899 anyhow::bail!("Router {router} is not in the configured `router_addresses` allowlist");
900 }
901
902 if !amount.is_zero()
903 && !self
904 .transaction_limits
905 .allowed_token_pairs
906 .iter()
907 .any(|(token_in, _)| *token_in == token)
908 {
909 anyhow::bail!(
910 "Token {token} is not an input token in the configured `allowed_token_pairs`"
911 );
912 }
913
914 self.ensure_transaction_ready(TransactionPurpose::Approve)?;
915
916 let approval_amount = if amount.is_zero() {
917 U256::ZERO
918 } else if self.config.unlimited_approval {
919 U256::MAX
920 } else {
921 amount
922 };
923 let calldata = ERC20::approveCall {
924 spender: router,
925 amount: approval_amount,
926 }
927 .abi_encode();
928
929 let executor = self.transaction_executor()?;
930 let included = executor
931 .transact(
932 token,
933 U256::ZERO,
934 Bytes::from(calldata),
935 TransactionPurpose::Approve,
936 None,
937 TransactionAuthorization::Approve {
938 token,
939 router,
940 amount: approval_amount,
941 },
942 )
943 .await?;
944 let postconditions =
945 verify_approve_allowance(&executor, &token, &router, approval_amount, &included)
946 .await?;
947 executor
948 .commit_verified_finality(&included, TransactionStatus::Finalized, &postconditions)
949 .await?;
950 executor
951 .database
952 .mark_execution_event_emitted(included.intent_id, "terminal")
953 .await?;
954 executor.release_slot();
955
956 Ok(included.tx_hash)
957 }
958
959 fn uniswap_v3_factory(&self) -> anyhow::Result<Address> {
960 crate::exchanges::get_dex_extended(self.chain.name, &DexType::UniswapV3)
961 .map(|dex| dex.factory)
962 .ok_or_else(|| {
963 anyhow::anyhow!(
964 "No registered Uniswap V3 deployment for chain {}",
965 self.chain.name
966 )
967 })
968 }
969
970 fn resolve_pool(&self, instrument_id: &InstrumentId) -> anyhow::Result<Pool> {
972 let (blockchain, dex_type) = instrument_id.venue.parse_dex()?;
973 if blockchain != self.chain.name {
974 anyhow::bail!(
975 "Pool venue chain {blockchain} does not match the client chain {}",
976 self.chain.name
977 );
978 }
979
980 if dex_type != DexType::UniswapV3 {
981 anyhow::bail!("Unsupported DEX type {dex_type}; only UniswapV3 is supported");
982 }
983
984 let pool_identifier = PoolIdentifier::new_checked(instrument_id.symbol.as_str())?;
985 if !pool_identifier.is_address() {
986 anyhow::bail!(
987 "Pool identifier {pool_identifier} is a pool ID; only address identifiers are supported"
988 );
989 }
990
991 let pool = self
992 .core
993 .cache()
994 .pool(instrument_id)
995 .cloned()
996 .ok_or_else(|| {
997 anyhow::anyhow!(
998 "Unknown pool {instrument_id}; not found in the shared engine cache"
999 )
1000 })?;
1001
1002 if pool.token0.get_token_priority() == pool.token1.get_token_priority() {
1003 anyhow::bail!(
1004 "Pool {instrument_id} tokens share a token priority; base and quote orientation is ambiguous"
1005 );
1006 }
1007
1008 Ok(pool)
1009 }
1010
1011 pub async fn check_payload_storage(
1022 &self,
1023 batch_size: usize,
1024 ) -> anyhow::Result<PayloadStorageCheck> {
1025 let batch_size = validate_payload_operation_batch_size(batch_size)?;
1026 let database = self.payload_operation_database().await?;
1027 let keys = self.load_payload_keys()?;
1028 database
1029 .check_execution_payload_storage(keys.as_ref(), None, batch_size)
1030 .await
1031 .map(Into::into)
1032 }
1033
1034 pub async fn protect_payload_storage(&self) -> anyhow::Result<()> {
1044 let database = self.payload_operation_database().await?;
1045 database.ensure_execution_transaction_schema().await?;
1046 let keys = self
1047 .load_payload_keys()?
1048 .ok_or_else(|| anyhow::anyhow!("Payload protection requires an active payload key"))?;
1049 database.ensure_execution_payload_storage(&keys).await
1050 }
1051
1052 pub async fn rewrap_payload_storage(&self, batch_size: usize) -> anyhow::Result<()> {
1062 let batch_size = validate_payload_operation_batch_size(batch_size)?;
1063 let database = self.payload_operation_database().await?;
1064 let keys = self
1065 .load_payload_keys()?
1066 .ok_or_else(|| anyhow::anyhow!("Payload rewrap requires an active payload key"))?;
1067 database
1068 .rewrap_execution_payload_storage(&keys, batch_size)
1069 .await
1070 }
1071
1072 pub async fn rollback_payload_storage(&self, batch_size: usize) -> anyhow::Result<()> {
1082 let batch_size = validate_payload_operation_batch_size(batch_size)?;
1083 let database = self.payload_operation_database().await?;
1084 let keys = self
1085 .load_payload_keys()?
1086 .ok_or_else(|| anyhow::anyhow!("Payload rollback requires an active payload key"))?;
1087 database
1088 .rollback_execution_payload_storage(&keys, batch_size)
1089 .await
1090 }
1091
1092 async fn payload_operation_database(&self) -> anyhow::Result<BlockchainCacheDatabase> {
1093 anyhow::ensure!(
1094 !self.core.is_connected(),
1095 "Disconnect the execution client before payload storage operations"
1096 );
1097
1098 if let Some(database) = &self.cache.database {
1099 return Ok(database.clone());
1100 }
1101 let options = self
1102 .config
1103 .postgres_cache_database_config
1104 .as_ref()
1105 .ok_or_else(|| anyhow::anyhow!("No Postgres cache database is configured"))?;
1106 BlockchainCacheDatabase::connect(options.clone().into())
1107 .await
1108 .context("failed to connect to the execution database")
1109 }
1110
1111 fn load_payload_keys(&self) -> anyhow::Result<Option<PayloadKeySet>> {
1112 PayloadKeySet::load(
1113 self.config.payload_key_env.as_deref(),
1114 &self.config.payload_key_retired_env,
1115 self.config.payload_deployment_id.as_deref(),
1116 )
1117 }
1118
1119 fn payload_policy(&self) -> PayloadPolicy {
1120 PayloadPolicy {
1121 chain_id: self.chain.chain_id,
1122 signer: self.wallet_address,
1123 gas_limit: self.config.gas_limit,
1124 max_fee_per_gas: self.config.max_fee_per_gas_wei,
1125 }
1126 }
1127
1128 fn transaction_executor(&self) -> anyhow::Result<TransactionExecutor> {
1134 let database = self.cache.database.clone().ok_or_else(|| {
1135 anyhow::anyhow!("No durable store configured; refusing to submit a transaction")
1136 })?;
1137 let signer = self
1138 .signer
1139 .clone()
1140 .ok_or_else(|| anyhow::anyhow!("Signer not initialized; connect the client first"))?;
1141 let payload_keys = self.payload_keys.clone().ok_or_else(|| {
1142 anyhow::anyhow!("Protected payload keys are not initialized; connect the client first")
1143 })?;
1144 let verification_config = self
1145 .config
1146 .verification
1147 .as_ref()
1148 .expect("verification config validated at construction");
1149 let identities = std::iter::once(&verification_config.authoritative)
1150 .chain(
1151 verification_config
1152 .verifiers
1153 .iter()
1154 .map(|provider| &provider.identity),
1155 )
1156 .collect::<Vec<_>>();
1157
1158 Ok(TransactionExecutor {
1159 http_rpc_client: self.http_rpc_client.clone(),
1160 verification: self.verification.clone(),
1161 manifest_version: verification_config.manifest_version.clone(),
1162 manifest_digest: verification_config.manifest_digest.clone(),
1163 deployment_manifest: Arc::new(verification_config.deployment_manifest.clone()),
1164 provider_ids: identities
1165 .iter()
1166 .map(|identity| identity.provider_id.clone())
1167 .collect(),
1168 operator_ids: identities
1169 .iter()
1170 .map(|identity| identity.operator_id.clone())
1171 .collect(),
1172 failure_domain_ids: identities
1173 .iter()
1174 .flat_map(|identity| identity.failure_domain_ids.iter().cloned())
1175 .collect(),
1176 database,
1177 signer,
1178 payload_keys,
1179 in_flight: Arc::clone(&self.in_flight),
1180 wallet_balance: Arc::clone(&self.wallet_balance),
1181 account_id: self.core.account_id,
1182 wallet_address: self.wallet_address,
1183 chain_id: self.chain.chain_id,
1184 max_fee_per_gas_wei: self.config.max_fee_per_gas_wei,
1185 base_fee_buffer_bps: self.config.base_fee_buffer_bps,
1186 gas_limit: self.config.gas_limit,
1187 gas_buffer_bps: self.config.gas_buffer_bps,
1188 receipt_timeout: receipt_timeout(self.transaction_limits.receipt_timeout_secs),
1189 receipt_max_polls: receipt_max_polls(self.transaction_limits.receipt_timeout_secs),
1190 })
1191 }
1192
1193 fn restore_swap_plan(&self, intent: &ExecutionIntentRow) -> anyhow::Result<SwapPlan> {
1194 let client_order_id = ClientOrderId::new_checked(
1195 intent
1196 .client_order_id
1197 .as_deref()
1198 .ok_or_else(|| anyhow::anyhow!("Persisted swap intent has no client order ID"))?,
1199 )?;
1200 let order = self
1201 .core
1202 .cache()
1203 .try_order_owned(&client_order_id)
1204 .with_context(|| {
1205 format!(
1206 "Cannot reconcile swap intent {} because order {client_order_id} is not restored",
1207 intent.id
1208 )
1209 })?;
1210 let instrument_id = InstrumentId::from_str(
1211 intent
1212 .instrument_id
1213 .as_deref()
1214 .ok_or_else(|| anyhow::anyhow!("Persisted swap intent has no instrument ID"))?,
1215 )?;
1216 anyhow::ensure!(
1217 order.instrument_id() == instrument_id,
1218 "Persisted swap instrument {instrument_id} does not match restored order instrument {}",
1219 order.instrument_id()
1220 );
1221 anyhow::ensure!(
1222 intent.trader_id.as_deref() == Some(order.trader_id().as_str()),
1223 "Persisted swap trader does not match restored order"
1224 );
1225 anyhow::ensure!(
1226 intent.strategy_id.as_deref() == Some(order.strategy_id().as_str()),
1227 "Persisted swap strategy does not match restored order"
1228 );
1229 anyhow::ensure!(
1230 intent.account_id.as_deref() == Some(self.core.account_id.as_str()),
1231 "Persisted swap account does not match execution client account"
1232 );
1233
1234 let pool = self.resolve_pool(&instrument_id)?;
1235 let pool_address = Address::from_str(
1236 intent
1237 .pool_address
1238 .as_deref()
1239 .ok_or_else(|| anyhow::anyhow!("Persisted swap intent has no pool address"))?,
1240 )?;
1241 anyhow::ensure!(
1242 pool.address == pool_address,
1243 "Persisted pool {pool_address} does not match restored pool {}",
1244 pool.address
1245 );
1246 let amount_in = U256::from_str(
1247 intent
1248 .amount_in
1249 .as_deref()
1250 .ok_or_else(|| anyhow::anyhow!("Persisted swap intent has no input amount"))?,
1251 )?;
1252 let fee = U24::try_from(
1253 pool.fee
1254 .ok_or_else(|| anyhow::anyhow!("Restored pool {instrument_id} has no fee"))?,
1255 )?;
1256 let quote_token = pool.get_quote_token();
1257 let quote_currency = Currency::new_checked(
1258 "e_token.symbol,
1259 quote_token.decimals,
1260 0,
1261 "e_token.name,
1262 CurrencyType::Crypto,
1263 )?;
1264 let (token_in, token_out) = swap_token_pair(
1265 order.order_side(),
1266 pool.get_base_token().address,
1267 quote_token.address,
1268 )?;
1269 let factory = self.uniswap_v3_factory()?;
1270 anyhow::ensure!(
1271 pool.dex.factory == factory,
1272 "Restored pool {instrument_id} references factory {}, expected registered factory {factory}",
1273 pool.dex.factory
1274 );
1275
1276 Ok(SwapPlan {
1277 order,
1278 quote_currency,
1279 pool,
1280 instrument_id,
1281 pool_address,
1282 router: Address::from_str(&intent.transaction_to)?,
1283 factory,
1284 weth: self.weth_address,
1285 token_in,
1286 token_out,
1287 fee,
1288 amount_in,
1289 min_amount_out: U256::ZERO,
1290 slippage_bps: 0,
1291 quote_spend_ceiling: None,
1292 profiler_position: None,
1293 })
1294 }
1295
1296 async fn reconcile_unresolved_execution(&self) -> anyhow::Result<()> {
1297 let database = self.cache.database.clone().ok_or_else(|| {
1298 anyhow::anyhow!("No durable store configured for execution reconciliation")
1299 })?;
1300 let _payload_lease = database
1301 .acquire_execution_payload_lease(self.payload_keys.as_deref().ok_or_else(|| {
1302 anyhow::anyhow!("Protected payload keys are required for execution recovery")
1303 })?)
1304 .await?;
1305 let wallet_address = self.wallet_address.to_string();
1306 anyhow::ensure!(
1307 !database
1308 .has_recoverable_signed_execution(self.chain.chain_id, &wallet_address)
1309 .await?,
1310 "A recoverable execution for wallet {} retains signed transaction bytes; refusing to reuse its nonce without explicit recovery",
1311 self.wallet_address
1312 );
1313 let Some(intent) = database
1314 .get_active_execution_intent(self.chain.chain_id, &wallet_address)
1315 .await?
1316 else {
1317 return Ok(());
1318 };
1319 anyhow::ensure!(
1320 intent.schema_version == crate::execution::transaction::EXECUTION_SCHEMA_VERSION,
1321 "Execution intent {} uses unsupported schema version {}",
1322 intent.id,
1323 intent.schema_version
1324 );
1325
1326 if intent.status == "prepared" {
1327 database
1328 .mark_execution_intent_recoverable(intent.id)
1329 .await?;
1330 release_preparing_slot(&self.in_flight);
1331 return Ok(());
1332 }
1333
1334 let purpose = TransactionPurpose::parse(&intent.purpose).ok_or_else(|| {
1335 anyhow::anyhow!(
1336 "Execution intent {} has unknown purpose {}",
1337 intent.id,
1338 intent.purpose
1339 )
1340 })?;
1341 let nonce = intent
1342 .nonce
1343 .ok_or_else(|| anyhow::anyhow!("Active execution intent {} has no nonce", intent.id))?;
1344 *self.in_flight.lock() = Some(InFlightSlot::Recovering(RecoveryTransaction {
1345 intent_id: intent.id,
1346 nonce,
1347 purpose,
1348 }));
1349 let hashes = database.get_execution_transaction_hashes(intent.id).await?;
1350 let current = current_execution_hash(intent.id, &hashes)?;
1351 let tx_hash = B256::from_str(¤t.transaction_hash).with_context(|| {
1352 format!(
1353 "Execution intent {} has invalid transaction hash {}",
1354 intent.id, current.transaction_hash
1355 )
1356 })?;
1357 let policy = PayloadPolicy {
1358 chain_id: self.chain.chain_id,
1359 signer: self.wallet_address,
1360 gas_limit: self.config.gas_limit,
1361 max_fee_per_gas: self.config.max_fee_per_gas_wei,
1362 };
1363 let mut authenticated_payloads = HashMap::new();
1364 let mut current_payload = None;
1365
1366 for hash in &hashes {
1367 anyhow::ensure!(
1368 hash.intent_id == intent.id,
1369 "Persisted transaction row references intent {}, expected {}",
1370 hash.intent_id,
1371 intent.id
1372 );
1373 anyhow::ensure!(
1374 hash.chain_id == self.chain.chain_id,
1375 "Persisted transaction row chain ID {} does not match configured chain ID {}",
1376 hash.chain_id,
1377 self.chain.chain_id
1378 );
1379
1380 if !hash.payload_expected {
1381 anyhow::ensure!(
1382 hash.raw_transaction.is_none() && hash.sealed_transaction.is_none(),
1383 "Replacement transaction {} unexpectedly retains signed bytes",
1384 hash.transaction_hash
1385 );
1386 continue;
1387 }
1388 let raw_transaction = open_execution_payload(
1389 self.payload_keys
1390 .as_deref()
1391 .expect("payload keys checked above"),
1392 policy,
1393 &intent,
1394 hash,
1395 "recovery",
1396 )
1397 .map_err(|e| {
1398 anyhow::anyhow!(
1399 "Execution intent {} signed transaction {} failed authentication: {e}",
1400 intent.id,
1401 hash.transaction_hash
1402 )
1403 })?;
1404 let authenticated_hash = B256::from_str(&hash.transaction_hash).with_context(|| {
1405 format!(
1406 "Execution intent {} has invalid transaction hash {}",
1407 intent.id, hash.transaction_hash
1408 )
1409 })?;
1410 anyhow::ensure!(
1411 authenticated_payloads
1412 .insert(authenticated_hash, raw_transaction.clone())
1413 .is_none(),
1414 "Execution intent {} has duplicate authenticated transaction hash {}",
1415 intent.id,
1416 hash.transaction_hash
1417 );
1418
1419 if hash.id == current.id {
1420 current_payload = Some(raw_transaction);
1421 }
1422 }
1423 anyhow::ensure!(
1424 !authenticated_payloads.is_empty(),
1425 "Execution intent {} has no persisted signed transaction bytes",
1426 intent.id
1427 );
1428
1429 if intent.status == "broadcast" {
1430 anyhow::ensure!(
1431 current_payload.is_some(),
1432 "Broadcast execution intent {} has no persisted signed transaction bytes",
1433 intent.id
1434 );
1435 }
1436
1437 if intent.status == "signed" {
1438 anyhow::bail!(
1439 "Execution intent {} has a signed transaction {} that was not authorized for broadcast; its nonce remains reserved pending explicit recovery",
1440 intent.id,
1441 tx_hash
1442 );
1443 }
1444
1445 *self.in_flight.lock() = Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
1446 intent_id: intent.id,
1447 nonce,
1448 tx_hash,
1449 purpose,
1450 }));
1451
1452 let plan = if purpose == TransactionPurpose::Swap {
1453 Some(self.restore_swap_plan(&intent)?)
1454 } else {
1455 None
1456 };
1457
1458 if let Some(plan) = &plan
1459 && !intent.acknowledgement_emitted
1460 {
1461 if plan.order.ts_submitted().is_none() {
1462 self.emitter.emit_order_submitted(&plan.order);
1463 }
1464 database
1465 .mark_execution_event_emitted(intent.id, "acknowledgement")
1466 .await?;
1467 }
1468
1469 let executor = self.transaction_executor()?;
1470 let mut prepared = PreparedTransaction {
1471 intent_id: intent.id,
1472 created_block: intent.created_block,
1473 nonce,
1474 tx_hash,
1475 raw_tx: current_payload.unwrap_or_default(),
1476 payload_lease: None,
1477 };
1478
1479 let finality_already_committed = matches!(intent.status.as_str(), "finalized" | "reverted");
1480 if !finality_already_committed {
1481 match executor
1482 .authorize_rebroadcast(&prepared, &intent, purpose)
1483 .await?
1484 {
1485 ReconciliationAuthorization::Rebroadcast => {
1486 match executor.broadcast(&prepared).await? {
1487 BroadcastOutcome::Accepted => {}
1488 BroadcastOutcome::Ambiguous(message) => log::warn!("{message}"),
1489 }
1490 }
1491 ReconciliationAuthorization::Retain => {
1492 log::warn!(
1493 "Rebroadcast of transaction {} was suppressed by verified reconciliation state",
1494 prepared.tx_hash
1495 );
1496 }
1497 ReconciliationAuthorization::ScanReplacement(head) => {
1498 let Some((replacement_hash, replacement_payload)) = executor
1499 .scan_canonical_replacement(&intent, nonce, head, &authenticated_payloads)
1500 .await?
1501 else {
1502 log::warn!(
1503 "Canonical replacement scan for intent {} reached its bounded verified window",
1504 intent.id
1505 );
1506 return Ok(());
1507 };
1508 prepared.tx_hash = replacement_hash;
1509 prepared.raw_tx = replacement_payload;
1510 *self.in_flight.lock() =
1511 Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
1512 intent_id: intent.id,
1513 nonce,
1514 tx_hash: replacement_hash,
1515 purpose,
1516 }));
1517 }
1518 }
1519 }
1520 let outcome = if finality_already_committed {
1521 let receipt = verified_value(
1522 executor.verification.verify_receipt(&tx_hash).await,
1523 "persisted terminal receipt",
1524 )?;
1525 let finality = executor
1526 .receipt_is_stably_finalized(&receipt)
1527 .await?
1528 .ok_or_else(|| {
1529 anyhow::anyhow!(
1530 "Persisted terminal transaction {tx_hash} is not stable at the finalized boundary"
1531 )
1532 })?;
1533 let included = IncludedTransaction {
1534 intent_id: intent.id,
1535 nonce,
1536 tx_hash,
1537 block_number: receipt.block_number,
1538 receipt,
1539 finality,
1540 };
1541
1542 if intent.status == "finalized" {
1543 InclusionOutcome::Finalized(included)
1544 } else {
1545 InclusionOutcome::Reverted(included)
1546 }
1547 } else {
1548 executor.await_finality(&prepared).await?
1549 };
1550
1551 match outcome {
1552 InclusionOutcome::Finalized(mut included) => {
1553 let trace_purpose = match (&plan, purpose) {
1554 (Some(plan), TransactionPurpose::Swap) => match plan.order.order_side() {
1555 OrderSide::Sell => "swap_sell",
1556 OrderSide::Buy => "swap_buy",
1557 },
1558 (None, TransactionPurpose::Wrap) => "wrap",
1559 (None, TransactionPurpose::Approve) => "approve",
1560 _ => anyhow::bail!("Restored transaction purpose is inconsistent"),
1561 };
1562 included.finality.decisions.extend(
1563 verify_finalized_transaction(
1564 &included,
1565 &intent,
1566 nonce,
1567 &prepared.raw_tx,
1568 &executor,
1569 trace_purpose,
1570 )
1571 .await?,
1572 );
1573
1574 if let Some(plan) = plan {
1575 let fill = validate_finalized_swap_fill(&plan, &included)?;
1576 let wallet =
1577 load_verified_wallet_after_fill(&plan, &included, &executor).await?;
1578 if !finality_already_committed {
1579 executor
1580 .commit_verified_finality(
1581 &included,
1582 TransactionStatus::Finalized,
1583 &wallet.decisions,
1584 )
1585 .await?;
1586 }
1587 complete_finalized_swap(
1588 &plan,
1589 intent.id,
1590 included.tx_hash,
1591 fill,
1592 wallet,
1593 &executor,
1594 &self.emitter,
1595 )
1596 .await?;
1597 executor.release_slot();
1598 } else {
1599 let postconditions = self
1600 .verify_recovered_operator_transaction(
1601 &intent, purpose, &included, &executor,
1602 )
1603 .await?;
1604
1605 if !finality_already_committed {
1606 executor
1607 .commit_verified_finality(
1608 &included,
1609 TransactionStatus::Finalized,
1610 &postconditions,
1611 )
1612 .await?;
1613 }
1614 database
1615 .mark_execution_event_emitted(intent.id, "terminal")
1616 .await?;
1617 executor.release_slot();
1618 }
1619 }
1620 InclusionOutcome::Reverted(mut included) => {
1621 let trace_purpose = match (&plan, purpose) {
1622 (Some(plan), TransactionPurpose::Swap) => match plan.order.order_side() {
1623 OrderSide::Sell => "swap_sell",
1624 OrderSide::Buy => "swap_buy",
1625 },
1626 (None, TransactionPurpose::Wrap) => "wrap",
1627 (None, TransactionPurpose::Approve) => "approve",
1628 _ => anyhow::bail!("Restored transaction purpose is inconsistent"),
1629 };
1630 included.finality.decisions.extend(
1631 verify_finalized_transaction(
1632 &included,
1633 &intent,
1634 nonce,
1635 &prepared.raw_tx,
1636 &executor,
1637 trace_purpose,
1638 )
1639 .await?,
1640 );
1641
1642 if !finality_already_committed {
1643 executor
1644 .commit_verified_finality(&included, TransactionStatus::Reverted, &[])
1645 .await?;
1646 }
1647
1648 if let Some(plan) = plan
1649 && plan.order.status() != OrderStatus::Rejected
1650 {
1651 send_reverted_order(&self.emitter, &plan.order, &included)?;
1652 }
1653 database
1654 .mark_execution_event_emitted(intent.id, "terminal")
1655 .await?;
1656 executor.release_slot();
1657 }
1658 InclusionOutcome::Pending(message) => log::warn!("{message}"),
1659 }
1660 Ok(())
1661 }
1662
1663 async fn verify_recovered_operator_transaction(
1669 &self,
1670 intent: &ExecutionIntentRow,
1671 purpose: TransactionPurpose,
1672 included: &IncludedTransaction,
1673 executor: &TransactionExecutor,
1674 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
1675 let (to, input, value) = persisted_call_fields(intent)?;
1676
1677 match purpose {
1678 TransactionPurpose::Wrap => {
1679 verify_wrap_balance_increase(executor, &to, value, included).await
1680 }
1681 TransactionPurpose::Approve => {
1682 let call = ERC20::approveCall::abi_decode(&input)
1683 .with_context(|| "persisted approve calldata is invalid")?;
1684 verify_approve_allowance(executor, &to, &call.spender, call.amount, included).await
1685 }
1686 TransactionPurpose::Swap => {
1687 unreachable!("swap intents restore a swap plan")
1688 }
1689 }
1690 }
1691
1692 fn ensure_transaction_ready(&self, purpose: TransactionPurpose) -> anyhow::Result<()> {
1693 if !self.core.is_connected() {
1694 anyhow::bail!("Blockchain execution client is not connected");
1695 }
1696
1697 {
1698 let slot = self.in_flight.lock();
1699 if let Some(in_flight) = *slot {
1700 return Err(in_flight_limit_error(&in_flight));
1701 }
1702 }
1703
1704 if !self.cache.has_database() {
1705 anyhow::bail!(
1706 "No durable store configured; refusing to submit a {} transaction",
1707 purpose.as_str()
1708 );
1709 }
1710 Ok(())
1711 }
1712
1713 fn prepare_swap(&self, cmd: &SubmitOrder, order: &OrderAny) -> anyhow::Result<SwapPlan> {
1720 let instrument_id = order.instrument_id();
1721 let pool = self.resolve_pool(&instrument_id)?;
1722
1723 if order.order_type() != OrderType::Market {
1724 anyhow::bail!(
1725 "Unsupported order type {}; only Market is supported",
1726 order.order_type()
1727 );
1728 }
1729
1730 if !matches!(order.order_side(), OrderSide::Buy | OrderSide::Sell) {
1731 anyhow::bail!(
1732 "Unsupported order side {}; only Buy and Sell are supported",
1733 order.order_side()
1734 );
1735 }
1736
1737 if order.is_quote_quantity() {
1738 anyhow::bail!(
1739 "Quote-denominated quantities are not supported; quantity must be denominated in the base token"
1740 );
1741 }
1742
1743 let fee = pool
1744 .fee
1745 .ok_or_else(|| anyhow::anyhow!("Pool {instrument_id} has no fee tier"))?;
1746 let fee = U24::try_from(fee)
1747 .map_err(|_| anyhow::anyhow!("Pool {instrument_id} fee {fee} exceeds uint24"))?;
1748
1749 let base_token = pool.get_base_token();
1750 let quote_token = pool.get_quote_token();
1751 let quote_currency = Currency::new_checked(
1752 "e_token.symbol,
1753 quote_token.decimals,
1754 0,
1755 "e_token.name,
1756 CurrencyType::Crypto,
1757 )?;
1758 let (token_in, token_out) =
1759 swap_token_pair(order.order_side(), base_token.address, quote_token.address)?;
1760
1761 if !self
1762 .transaction_limits
1763 .allowed_token_pairs
1764 .contains(&(token_in, token_out))
1765 {
1766 anyhow::bail!(
1767 "Token pair {token_in} -> {token_out} is not in the `allowed_token_pairs` allowlist"
1768 );
1769 }
1770
1771 let base_amount = quantity_to_raw_amount(order.quantity(), base_token.decimals)?;
1772 if base_amount > U256::from(self.transaction_limits.max_order_amount) {
1773 anyhow::bail!(
1774 "Order amount {base_amount} exceeds the configured `max_order_amount` {}",
1775 self.transaction_limits.max_order_amount
1776 );
1777 }
1778
1779 let slippage_bps = match cmd
1780 .params
1781 .as_ref()
1782 .and_then(|params| params.get_u64("slippage_bps"))
1783 {
1784 Some(value) => u32::try_from(value).map_err(|_| {
1785 anyhow::anyhow!("slippage_bps parameter {value} exceeds the u32 range")
1786 })?,
1787 None => self.transaction_limits.slippage_bps,
1788 };
1789
1790 if slippage_bps > self.transaction_limits.max_slippage_bps {
1791 anyhow::bail!(
1792 "Slippage {slippage_bps} bps exceeds the configured `max_slippage_bps` {}",
1793 self.transaction_limits.max_slippage_bps
1794 );
1795 }
1796
1797 let quote_spend_ceiling = if order.order_side() == OrderSide::Buy {
1798 let ceiling = self
1799 .transaction_limits
1800 .quote_spend_limits
1801 .get(&(token_in, token_out))
1802 .ok_or_else(|| {
1803 anyhow::anyhow!(
1804 "No `quote_spend_limits` entry for BUY token pair {token_in} -> {token_out}"
1805 )
1806 })?;
1807 anyhow::ensure!(
1808 ceiling.spend_token == quote_token.address,
1809 "Quote spend limit for {token_in} -> {token_out} is denominated in {}, expected quote token {}",
1810 ceiling.spend_token,
1811 quote_token.address
1812 );
1813 anyhow::ensure!(
1814 ceiling.spend_token_decimals == quote_token.decimals,
1815 "Quote spend limit for token {} uses {} decimals, expected pool quote-token decimals {}",
1816 ceiling.spend_token,
1817 ceiling.spend_token_decimals,
1818 quote_token.decimals
1819 );
1820 Some(ceiling)
1821 } else {
1822 None
1823 };
1824
1825 let profiler = self
1826 .core
1827 .cache()
1828 .pool_profiler(&instrument_id)
1829 .cloned()
1830 .ok_or_else(|| {
1831 anyhow::anyhow!(
1832 "No pool profiler for {instrument_id}; an active data subscription is required to quote the swap"
1833 )
1834 })?;
1835
1836 if !profiler.is_initialized {
1837 anyhow::bail!("Pool profiler for {instrument_id} is not initialized");
1838 }
1839 let profiler_position = profiler.last_processed_event.clone().ok_or_else(|| {
1840 anyhow::anyhow!("Pool profiler for {instrument_id} has processed no events")
1841 })?;
1842
1843 let zero_for_one = token_in == pool.token0.address;
1844 let (amount_in, quoted_amount_out) = match order.order_side() {
1845 OrderSide::Sell => {
1846 let quote = profiler
1847 .swap_exact_in(base_amount, zero_for_one, None)
1848 .map_err(|e| anyhow::anyhow!("Swap quote failed for {instrument_id}: {e}"))?;
1849 let amount_filled = if zero_for_one {
1850 quote.amount0
1851 } else {
1852 quote.amount1
1853 };
1854
1855 if amount_filled != I256::from(base_amount) {
1856 anyhow::bail!(
1857 "Local quote for {instrument_id} filled {amount_filled} of the {base_amount} order amount; pool liquidity cannot fill the order"
1858 );
1859 }
1860 (base_amount, exact_output_amount("e, zero_for_one)?)
1861 }
1862 OrderSide::Buy => {
1863 let quote = profiler
1864 .swap_exact_out(base_amount, zero_for_one, None)
1865 .map_err(|e| anyhow::anyhow!("Swap quote failed for {instrument_id}: {e}"))?;
1866 let amount_in = quote.get_input_amount();
1867 if amount_in.is_zero() {
1868 anyhow::bail!("Local quote for {instrument_id} produced a zero quote input");
1869 }
1870 let ceiling = quote_spend_ceiling.ok_or_else(|| {
1871 anyhow::anyhow!(
1872 "No `quote_spend_limits` entry for BUY token pair {token_in} -> {token_out}"
1873 )
1874 })?;
1875
1876 if amount_in > ceiling.max_amount {
1877 anyhow::bail!(
1878 "BUY quote amount {amount_in} exceeds the configured `quote_spend_limits` maximum {} for {token_in} -> {token_out}",
1879 ceiling.max_amount
1880 );
1881 }
1882 (amount_in, base_amount)
1883 }
1884 };
1885 let min_amount_out = derive_min_amount_out(quoted_amount_out, slippage_bps)?;
1886
1887 self.ensure_transaction_ready(TransactionPurpose::Swap)?;
1888
1889 if self.signer.is_none() {
1890 anyhow::bail!("Signer not initialized; connect the client first");
1891 }
1892
1893 let pool_address = pool.address;
1894 let factory = self.uniswap_v3_factory()?;
1895 anyhow::ensure!(
1896 pool.dex.factory == factory,
1897 "Pool {instrument_id} references factory {}, expected registered factory {factory}",
1898 pool.dex.factory
1899 );
1900
1901 Ok(SwapPlan {
1902 order: order.clone(),
1903 pool,
1904 quote_currency,
1905 instrument_id,
1906 pool_address,
1907 router: self.router_addresses[0],
1908 factory,
1909 weth: self.weth_address,
1910 token_in,
1911 token_out,
1912 fee,
1913 amount_in,
1914 min_amount_out,
1915 slippage_bps,
1916 quote_spend_ceiling: quote_spend_ceiling.copied(),
1917 profiler_position: Some(profiler_position),
1918 })
1919 }
1920}
1921
1922struct PreparedTransaction {
1924 intent_id: i64,
1925 created_block: u64,
1926 nonce: u64,
1927 tx_hash: B256,
1928 raw_tx: Vec<u8>,
1929 payload_lease: Option<ExecutionPayloadLease>,
1930}
1931
1932impl Debug for PreparedTransaction {
1933 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1934 f.debug_struct(stringify!(PreparedTransaction))
1935 .field("intent_id", &self.intent_id)
1936 .field("created_block", &self.created_block)
1937 .field("nonce", &self.nonce)
1938 .field("tx_hash", &self.tx_hash)
1939 .field("raw_tx", &"<redacted>")
1940 .field(
1941 "payload_lease",
1942 &self.payload_lease.as_ref().map(|_| "held"),
1943 )
1944 .finish()
1945 }
1946}
1947
1948#[derive(Debug)]
1950enum BroadcastOutcome {
1951 Accepted,
1953 Ambiguous(String),
1955}
1956
1957#[derive(Debug)]
1959enum InclusionOutcome {
1960 Finalized(IncludedTransaction),
1962 Reverted(IncludedTransaction),
1964 Pending(String),
1967}
1968
1969enum ReconciliationAuthorization {
1970 Rebroadcast,
1971 Retain,
1972 ScanReplacement(VerifiedBlockHeader),
1973}
1974
1975fn verified_value<T>(outcome: VerificationOutcome<T>, context: &str) -> anyhow::Result<T> {
1976 required_verification(outcome, context).map(|verified| verified.value)
1977}
1978
1979fn required_verification<T>(
1980 outcome: VerificationOutcome<T>,
1981 context: &str,
1982) -> anyhow::Result<Verified<T>> {
1983 match outcome {
1984 VerificationOutcome::Verified(verified) => Ok(verified),
1985 VerificationOutcome::Disagreement(_) => {
1986 anyhow::bail!("{context} verification disagreed")
1987 }
1988 VerificationOutcome::Unavailable(_) => {
1989 anyhow::bail!("{context} verification is unavailable")
1990 }
1991 VerificationOutcome::Retryable(_) => {
1992 anyhow::bail!("{context} verification is retryable")
1993 }
1994 VerificationOutcome::LocallyInvalid(_) => {
1995 anyhow::bail!("{context} verification is locally invalid")
1996 }
1997 }
1998}
1999
2000fn validate_transaction_authorization(
2001 authorization: Option<&TransactionAuthorization>,
2002 to: Address,
2003 value: U256,
2004 input: &[u8],
2005) -> anyhow::Result<()> {
2006 match authorization {
2007 None => Ok(()),
2008 Some(TransactionAuthorization::Wrap { weth }) => {
2009 anyhow::ensure!(
2010 to == *weth && !value.is_zero() && input == WETH9::depositCall::SELECTOR,
2011 "Wrap authorization does not match the transaction call"
2012 );
2013 Ok(())
2014 }
2015 Some(TransactionAuthorization::Approve {
2016 token,
2017 router,
2018 amount,
2019 }) => {
2020 let expected = ERC20::approveCall {
2021 spender: *router,
2022 amount: *amount,
2023 }
2024 .abi_encode();
2025 anyhow::ensure!(
2026 to == *token && value.is_zero() && input == expected,
2027 "Approve authorization does not match the transaction call"
2028 );
2029 Ok(())
2030 }
2031 }
2032}
2033
2034fn verification_decision<T>(
2035 verified: &Verified<T>,
2036 height_start: Option<u64>,
2037 height_end: Option<u64>,
2038) -> ExecutionVerificationDecision {
2039 ExecutionVerificationDecision {
2040 read_class: verified.read.as_str(),
2041 height_start,
2042 height_end,
2043 normalized_value_digest: verified.normalized_value_digest.to_string(),
2044 }
2045}
2046
2047fn parse_verified_header(header: &ExecutionVerifiedHeader) -> anyhow::Result<VerifiedBlockHeader> {
2048 Ok(VerifiedBlockHeader {
2049 number: header.number,
2050 hash: B256::from_str(&header.hash).context("Durable finalized header hash is invalid")?,
2051 parent_hash: B256::from_str(&header.parent_hash)
2052 .context("Durable finalized parent hash is invalid")?,
2053 timestamp: header.timestamp,
2054 base_fee_per_gas: header.base_fee_per_gas,
2055 })
2056}
2057
2058fn durable_verified_header(header: &VerifiedBlockHeader) -> ExecutionVerifiedHeader {
2059 ExecutionVerifiedHeader {
2060 number: header.number,
2061 hash: header.hash.to_string(),
2062 parent_hash: header.parent_hash.to_string(),
2063 timestamp: header.timestamp,
2064 base_fee_per_gas: header.base_fee_per_gas,
2065 }
2066}
2067
2068#[derive(Debug, Clone)]
2075struct TransactionExecutor {
2076 http_rpc_client: Arc<BlockchainHttpRpcClient>,
2077 verification: VerificationCoordinator,
2078 manifest_version: String,
2079 manifest_digest: String,
2080 deployment_manifest: Arc<BlockchainDeploymentManifest>,
2081 provider_ids: Vec<String>,
2082 operator_ids: Vec<String>,
2083 failure_domain_ids: Vec<String>,
2084 database: BlockchainCacheDatabase,
2085 signer: Arc<PrivateKeySigner>,
2086 payload_keys: Arc<PayloadKeySet>,
2087 in_flight: Arc<Mutex<Option<InFlightSlot>>>,
2088 wallet_balance: Arc<Mutex<WalletBalance>>,
2089 account_id: AccountId,
2090 wallet_address: Address,
2091 chain_id: u32,
2092 max_fee_per_gas_wei: u64,
2093 base_fee_buffer_bps: u32,
2094 gas_limit: u64,
2095 gas_buffer_bps: u32,
2096 receipt_timeout: Duration,
2097 receipt_max_polls: u32,
2098}
2099
2100impl TransactionExecutor {
2101 async fn transact(
2107 &self,
2108 to: Address,
2109 value: U256,
2110 input: Bytes,
2111 purpose: TransactionPurpose,
2112 client_order_id: Option<ClientOrderId>,
2113 authorization: TransactionAuthorization,
2114 ) -> anyhow::Result<IncludedTransaction> {
2115 self.claim_slot(purpose)?;
2116 let now_unix_secs = current_unix_secs()?;
2117 let decision_header = match required_verification(
2118 self.verification
2119 .verify_decision_header(now_unix_secs)
2120 .await,
2121 "operator decision header",
2122 ) {
2123 Ok(header) => header,
2124 Err(e) => {
2125 release_preparing_slot(&self.in_flight);
2126 return Err(e);
2127 }
2128 };
2129 let created_block = decision_header.value.number;
2130 let intent = ExecutionIntentInsert {
2131 chain_id: self.chain_id,
2132 wallet_address: self.wallet_address.to_string(),
2133 purpose: purpose.as_str().to_string(),
2134 client_order_id: client_order_id.map(|id| id.to_string()),
2135 trader_id: None,
2136 strategy_id: None,
2137 account_id: None,
2138 instrument_id: None,
2139 pool_address: None,
2140 transaction_to: to.to_string(),
2141 transaction_input: hex::encode_prefixed(&input),
2142 transaction_value: value.to_string(),
2143 amount_in: None,
2144 created_block,
2145 };
2146 let intent = match self.database.reserve_execution_intent(&intent).await {
2147 Ok(intent) => intent,
2148 Err(e) => {
2149 release_preparing_if_reservation_not_committed(&self.in_flight, &e);
2150 return Err(e);
2151 }
2152 };
2153 let prepared = match self
2154 .prepare_and_sign(
2155 intent.id,
2156 intent.created_block,
2157 to,
2158 value,
2159 input,
2160 &authorization,
2161 decision_header,
2162 )
2163 .await
2164 {
2165 Ok(prepared) => prepared,
2166 Err(e) => {
2167 if self
2168 .database
2169 .mark_execution_intent_recoverable(intent.id)
2170 .await
2171 .is_ok()
2172 {
2173 release_preparing_slot(&self.in_flight);
2174 }
2175 return Err(e);
2176 }
2177 };
2178 self.fill_and_persist(&prepared, purpose).await?;
2179
2180 match self.broadcast(&prepared).await? {
2181 BroadcastOutcome::Accepted => {}
2182 BroadcastOutcome::Ambiguous(message) => log::warn!("{message}"),
2183 }
2184
2185 match self.await_finality(&prepared).await? {
2186 InclusionOutcome::Finalized(mut included) => {
2187 included.finality.decisions.extend(
2188 verify_finalized_transaction(
2189 &included,
2190 &intent,
2191 prepared.nonce,
2192 &prepared.raw_tx,
2193 self,
2194 purpose.as_str(),
2195 )
2196 .await?,
2197 );
2198 Ok(included)
2199 }
2200 InclusionOutcome::Reverted(mut included) => {
2201 included.finality.decisions.extend(
2202 verify_finalized_transaction(
2203 &included,
2204 &intent,
2205 prepared.nonce,
2206 &prepared.raw_tx,
2207 self,
2208 purpose.as_str(),
2209 )
2210 .await?,
2211 );
2212 self.commit_verified_finality(&included, TransactionStatus::Reverted, &[])
2213 .await?;
2214 self.database
2215 .mark_execution_event_emitted(prepared.intent_id, "terminal")
2216 .await?;
2217 self.release_slot();
2218 anyhow::bail!("Transaction {} reverted on-chain", included.tx_hash)
2219 }
2220 InclusionOutcome::Pending(message) => anyhow::bail!(message),
2221 }
2222 }
2223
2224 fn claim_slot(&self, purpose: TransactionPurpose) -> anyhow::Result<()> {
2227 let mut slot = self.in_flight.lock();
2228 if let Some(in_flight) = *slot {
2229 return Err(in_flight_limit_error(&in_flight));
2230 }
2231 *slot = Some(InFlightSlot::Preparing(purpose));
2232 Ok(())
2233 }
2234
2235 #[expect(
2238 clippy::too_many_arguments,
2239 reason = "Security-critical transaction fields stay explicit at the signing boundary"
2240 )]
2241 async fn prepare_and_sign(
2242 &self,
2243 intent_id: i64,
2244 created_block: u64,
2245 to: Address,
2246 value: U256,
2247 input: Bytes,
2248 authorization: &TransactionAuthorization,
2249 decision_header: Verified<VerifiedBlockHeader>,
2250 ) -> anyhow::Result<PreparedTransaction> {
2251 self.prepare_and_sign_with_anchors(
2252 intent_id,
2253 created_block,
2254 to,
2255 value,
2256 input,
2257 None,
2258 Some(authorization),
2259 Some(decision_header),
2260 )
2261 .await
2262 }
2263
2264 async fn prepare_and_sign_swap(
2265 &self,
2266 intent_id: i64,
2267 created_block: u64,
2268 to: Address,
2269 value: U256,
2270 input: Bytes,
2271 anchors: &SwapQuoteAnchors,
2272 ) -> anyhow::Result<PreparedTransaction> {
2273 self.prepare_and_sign_with_anchors(
2274 intent_id,
2275 created_block,
2276 to,
2277 value,
2278 input,
2279 Some(anchors),
2280 None,
2281 None,
2282 )
2283 .await
2284 }
2285
2286 #[expect(
2287 clippy::too_many_arguments,
2288 reason = "Security-critical transaction fields and verification anchors stay explicit"
2289 )]
2290 async fn prepare_and_sign_with_anchors(
2291 &self,
2292 intent_id: i64,
2293 created_block: u64,
2294 to: Address,
2295 value: U256,
2296 input: Bytes,
2297 swap_anchors: Option<&SwapQuoteAnchors>,
2298 authorization: Option<&TransactionAuthorization>,
2299 decision_header: Option<Verified<VerifiedBlockHeader>>,
2300 ) -> anyhow::Result<PreparedTransaction> {
2301 let expected_chain_id = u64::from(self.chain_id);
2302 let chain_id_verification = required_verification(
2303 self.verification.verify_chain_id().await,
2304 "pre-sign chain ID",
2305 )?;
2306 let actual_chain_id = chain_id_verification.value;
2307 anyhow::ensure!(
2308 actual_chain_id == expected_chain_id,
2309 "Verified chain ID does not match the transaction chain"
2310 );
2311 let decision_header_verification = if let Some(anchors) = swap_anchors {
2312 required_verification(
2313 self.verification.verify_block(anchors.state.number).await,
2314 "pre-sign swap decision header reread",
2315 )?
2316 } else {
2317 match decision_header {
2318 Some(verified) => verified,
2319 None => {
2320 let now_unix_secs = current_unix_secs()?;
2321 required_verification(
2322 self.verification
2323 .verify_decision_header(now_unix_secs)
2324 .await,
2325 "pre-sign decision header",
2326 )?
2327 }
2328 }
2329 };
2330 let decision_header = decision_header_verification.value;
2331
2332 if let Some(anchors) = swap_anchors {
2333 anyhow::ensure!(
2334 decision_header == anchors.state,
2335 "Verified swap decision header changed before signing"
2336 );
2337 }
2338 let decision_ancestry = self.verify_decision_ancestry(decision_header).await?;
2339 validate_transaction_authorization(authorization, to, value, &input)?;
2340 let deployment_verification = required_verification(
2341 self.verification
2342 .verify_deployment_manifest(&self.deployment_manifest, decision_header.number)
2343 .await,
2344 "pre-sign deployment manifest",
2345 )?;
2346 let authorization_decisions = match authorization {
2347 Some(authorization) => {
2348 self.verify_transaction_authorization(authorization, decision_header.number)
2349 .await?
2350 }
2351 None => Vec::new(),
2352 };
2353 let base_fee_per_gas_wei = decision_header.base_fee_per_gas.ok_or_else(|| {
2354 anyhow::anyhow!(
2355 "Verified decision block {} has no base fee",
2356 decision_header.number
2357 )
2358 })?;
2359 let priority_fee_verification = required_verification(
2360 self.verification.verify_priority_fee().await,
2361 "pre-sign priority fee",
2362 )?;
2363 let priority_fee_per_gas_wei = priority_fee_verification.value;
2364 let (max_fee_per_gas, max_priority_fee_per_gas) = derive_fees(
2365 base_fee_per_gas_wei,
2366 priority_fee_per_gas_wei,
2367 self.base_fee_buffer_bps,
2368 u128::from(self.max_fee_per_gas_wei),
2369 )?;
2370 let gas_estimate_verification = required_verification(
2371 self.verification
2372 .verify_gas_estimate(
2373 &self.wallet_address,
2374 &to,
2375 value,
2376 &input,
2377 decision_header.number,
2378 )
2379 .await,
2380 "pre-sign gas estimate",
2381 )?;
2382 let gas_estimate = gas_estimate_verification.value;
2383 let gas_limit = derive_gas_limit(gas_estimate, self.gas_buffer_bps, self.gas_limit)?;
2384 let max_gas_cost = U256::from(gas_limit)
2385 .checked_mul(U256::from(max_fee_per_gas))
2386 .ok_or_else(|| anyhow::anyhow!("Maximum gas cost overflow"))?;
2387 let max_transaction_cost = value
2388 .checked_add(max_gas_cost)
2389 .ok_or_else(|| anyhow::anyhow!("Maximum transaction cost overflow"))?;
2390 let native_balance_verification = required_verification(
2391 self.verification
2392 .verify_balance(&self.wallet_address, decision_header.number)
2393 .await,
2394 "pre-sign native balance",
2395 )?;
2396 let native_balance = native_balance_verification.value;
2397
2398 if native_balance < max_transaction_cost {
2399 anyhow::bail!(
2400 "Native currency balance {native_balance} wei is below maximum transaction cost {max_transaction_cost} wei"
2401 );
2402 }
2403 let decision_height = Some(decision_header.number);
2404 let mut decisions = vec![
2405 verification_decision(&chain_id_verification, None, None),
2406 verification_decision(
2407 &decision_header_verification,
2408 decision_height,
2409 decision_height,
2410 ),
2411 verification_decision(&deployment_verification, decision_height, decision_height),
2412 verification_decision(&priority_fee_verification, None, None),
2413 verification_decision(&gas_estimate_verification, decision_height, decision_height),
2414 verification_decision(
2415 &native_balance_verification,
2416 decision_height,
2417 decision_height,
2418 ),
2419 ];
2420 decisions.extend(decision_ancestry);
2421 decisions.extend(authorization_decisions);
2422 if let Some(anchors) = swap_anchors {
2423 decisions.extend(self.verify_swap_anchors_before_sign(anchors).await?);
2424 decisions.extend(anchors.precondition_decisions.iter().cloned());
2425 } else {
2426 decisions.extend(self.verify_pre_sign_header_fence(decision_header).await?);
2427 }
2428 let canonical_nonce_verification = required_verification(
2429 self.verification
2430 .verify_transaction_count(&self.wallet_address, decision_header.number)
2431 .await,
2432 "pre-sign canonical nonce reread",
2433 )?;
2434 let pending_nonce_verification = required_verification(
2435 self.verification
2436 .verify_pending_transaction_count(&self.wallet_address)
2437 .await,
2438 "pre-sign pending nonce reread",
2439 )?;
2440 anyhow::ensure!(
2441 pending_nonce_verification.value == canonical_nonce_verification.value,
2442 "Pending nonce does not match the verified canonical nonce"
2443 );
2444 let nonce = canonical_nonce_verification.value;
2445 decisions.push(verification_decision(
2446 &canonical_nonce_verification,
2447 decision_height,
2448 decision_height,
2449 ));
2450 decisions.push(verification_decision(
2451 &pending_nonce_verification,
2452 None,
2453 None,
2454 ));
2455 let tx = build_eip1559_transaction(
2456 expected_chain_id,
2457 nonce,
2458 gas_limit,
2459 max_fee_per_gas,
2460 max_priority_fee_per_gas,
2461 to,
2462 value,
2463 input,
2464 );
2465 let wallet_address = self.wallet_address.to_string();
2466 self.database
2467 .assign_execution_intent_nonce_verified(&ExecutionNonceAssignment {
2468 intent_id,
2469 chain_id: self.chain_id,
2470 wallet_address: &wallet_address,
2471 nonce,
2472 manifest_version: &self.manifest_version,
2473 manifest_digest: &self.manifest_digest,
2474 provider_ids: &self.provider_ids,
2475 operator_ids: &self.operator_ids,
2476 failure_domain_ids: &self.failure_domain_ids,
2477 decisions: &decisions,
2478 })
2479 .await?;
2480 let payload_lease = self
2481 .database
2482 .acquire_execution_payload_lease(&self.payload_keys)
2483 .await?;
2484 let (tx_hash, raw_tx) = sign_eip1559_transaction(tx, &self.signer).await?;
2485
2486 Ok(PreparedTransaction {
2487 intent_id,
2488 created_block,
2489 nonce,
2490 tx_hash,
2491 raw_tx,
2492 payload_lease: Some(payload_lease),
2493 })
2494 }
2495
2496 async fn verify_pre_sign_header_fence(
2497 &self,
2498 target: VerifiedBlockHeader,
2499 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
2500 let checkpoint = required_verification(
2501 self.verification.verify_checkpoint().await,
2502 "pre-sign checkpoint reread",
2503 )?;
2504 let header = required_verification(
2505 self.verification.verify_block(target.number).await,
2506 "pre-sign decision header reread",
2507 )?;
2508 anyhow::ensure!(
2509 header.value == target,
2510 "Decision header changed before signing"
2511 );
2512 Ok(vec![
2513 verification_decision(
2514 &checkpoint,
2515 Some(checkpoint.value.number),
2516 Some(checkpoint.value.number),
2517 ),
2518 verification_decision(&header, Some(target.number), Some(target.number)),
2519 ])
2520 }
2521
2522 async fn verify_decision_ancestry(
2523 &self,
2524 target: VerifiedBlockHeader,
2525 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
2526 let checkpoint = required_verification(
2527 self.verification.verify_checkpoint().await,
2528 "pre-sign checkpoint",
2529 )?;
2530 anyhow::ensure!(
2531 checkpoint.value.number <= target.number,
2532 "Pre-sign decision header precedes the trusted checkpoint"
2533 );
2534 let mut decisions = vec![verification_decision(
2535 &checkpoint,
2536 Some(checkpoint.value.number),
2537 Some(checkpoint.value.number),
2538 )];
2539 let wallet_address = self.wallet_address.to_string();
2540 let position = self
2541 .database
2542 .load_execution_verification_position(
2543 self.chain_id,
2544 &wallet_address,
2545 &self.manifest_version,
2546 &self.manifest_digest,
2547 )
2548 .await?
2549 .ok_or_else(|| anyhow::anyhow!("Execution verification ledger is not initialized"))?;
2550 let durable_tip = parse_verified_header(&position.finalized_tip)?;
2551 anyhow::ensure!(
2552 durable_tip.number >= checkpoint.value.number && durable_tip.number <= target.number,
2553 "Pre-sign decision header does not extend the durable finalized header tip"
2554 );
2555 let durable_tip_verification = required_verification(
2556 self.verification.verify_block(durable_tip.number).await,
2557 "pre-sign durable finalized tip",
2558 )?;
2559 anyhow::ensure!(
2560 durable_tip_verification.value == durable_tip,
2561 "Durable finalized header tip conflicts with independent sources"
2562 );
2563
2564 if durable_tip != checkpoint.value {
2565 decisions.push(verification_decision(
2566 &durable_tip_verification,
2567 Some(durable_tip.number),
2568 Some(durable_tip.number),
2569 ));
2570 }
2571 let mut cursor = durable_tip;
2572 while cursor.number < target.number {
2573 let end = cursor.number.saturating_add(4_096).min(target.number);
2574 let start = cursor.number.saturating_add(1);
2575 let ancestry = required_verification(
2576 self.verification.verify_header_window(cursor, end).await,
2577 "pre-sign decision ancestry",
2578 )?;
2579 cursor = *ancestry
2580 .value
2581 .last()
2582 .expect("nonempty decision ancestry advances the cursor");
2583 decisions.push(verification_decision(&ancestry, Some(start), Some(end)));
2584 }
2585 anyhow::ensure!(
2586 cursor == target,
2587 "Pre-sign decision header conflicts with its trusted ancestry"
2588 );
2589 Ok(decisions)
2590 }
2591
2592 async fn verify_transaction_authorization(
2593 &self,
2594 authorization: &TransactionAuthorization,
2595 block: u64,
2596 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
2597 match *authorization {
2598 TransactionAuthorization::Wrap { weth } => {
2599 let call = ERC20::balanceOfCall {
2600 account: self.wallet_address,
2601 }
2602 .abi_encode();
2603 let balance = required_verification(
2604 self.verification
2605 .verify_decoded_call(None, &weth, U256::ZERO, &call, block, |result| {
2606 ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into)
2607 })
2608 .await,
2609 "pre-sign wrapped token probe",
2610 )?;
2611 Ok(vec![verification_decision(
2612 &balance,
2613 Some(block),
2614 Some(block),
2615 )])
2616 }
2617 TransactionAuthorization::Approve {
2618 token,
2619 router,
2620 amount,
2621 } => {
2622 let allowance_call = ERC20::allowanceCall {
2623 owner: self.wallet_address,
2624 spender: router,
2625 }
2626 .abi_encode();
2627 let allowance = required_verification(
2628 self.verification
2629 .verify_decoded_call(
2630 None,
2631 &token,
2632 U256::ZERO,
2633 &allowance_call,
2634 block,
2635 |result| {
2636 ERC20::allowanceCall::abi_decode_returns(result).map_err(Into::into)
2637 },
2638 )
2639 .await,
2640 "pre-sign router allowance",
2641 )?;
2642 anyhow::ensure!(
2643 allowance.value.is_zero() || amount.is_zero(),
2644 "Router allowance for token {token} is already {}; approve zero before setting a new nonzero allowance",
2645 allowance.value
2646 );
2647
2648 let approve_call = ERC20::approveCall {
2649 spender: router,
2650 amount,
2651 }
2652 .abi_encode();
2653 let simulation = required_verification(
2654 self.verification
2655 .verify_decoded_simulation(
2656 &self.wallet_address,
2657 &token,
2658 U256::ZERO,
2659 &approve_call,
2660 block,
2661 |result| {
2662 if result.is_empty() {
2663 Ok(true)
2664 } else {
2665 ERC20::approveCall::abi_decode_returns_validate(result)
2666 .map_err(Into::into)
2667 }
2668 },
2669 )
2670 .await,
2671 "pre-sign approval simulation",
2672 )?;
2673
2674 match &simulation.value {
2675 VerifiedSimulation::Succeeded(true) => {}
2676 VerifiedSimulation::Succeeded(false) => {
2677 anyhow::bail!("ERC-20 approve returned false for token {token}")
2678 }
2679 VerifiedSimulation::Denied => {
2680 anyhow::bail!("ERC-20 approve simulation reverted for token {token}")
2681 }
2682 }
2683 Ok(vec![
2684 verification_decision(&allowance, Some(block), Some(block)),
2685 verification_decision(&simulation, Some(block), Some(block)),
2686 ])
2687 }
2688 }
2689 }
2690
2691 async fn verify_swap_anchors_before_sign(
2692 &self,
2693 anchors: &SwapQuoteAnchors,
2694 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
2695 let checkpoint = required_verification(
2696 self.verification.verify_checkpoint().await,
2697 "pre-sign checkpoint reread",
2698 )?;
2699 let watermark = required_verification(
2700 self.verification
2701 .verify_block(anchors.watermark.number)
2702 .await,
2703 "pre-sign profiler watermark reread",
2704 )?;
2705 anyhow::ensure!(
2706 watermark.value == anchors.watermark,
2707 "Pool state header changed before signing"
2708 );
2709 let ancestry = required_verification(
2710 self.verification
2711 .verify_header_window(watermark.value, anchors.state.number)
2712 .await,
2713 "pre-sign profiler ancestry reread",
2714 )?;
2715
2716 if let Some(last) = ancestry.value.last() {
2717 anyhow::ensure!(
2718 *last == anchors.state,
2719 "Swap decision header changed in the pre-sign ancestry reread"
2720 );
2721 } else {
2722 anyhow::ensure!(
2723 watermark.value == anchors.state,
2724 "Empty pre-sign ancestry does not end at the swap decision header"
2725 );
2726 }
2727 let quote = match anchors.quote_kind {
2728 SwapQuoteKind::ExactInput(amount_in) => required_verification(
2729 self.verification
2730 .verify_quote_exact_input_single(
2731 &anchors.quote_contract,
2732 anchors.token_in,
2733 anchors.token_out,
2734 amount_in,
2735 anchors.fee,
2736 anchors.state.number,
2737 )
2738 .await,
2739 "pre-sign exact-input quote reread",
2740 )?,
2741 SwapQuoteKind::ExactOutput(amount_out) => required_verification(
2742 self.verification
2743 .verify_quote_exact_output_single(
2744 &anchors.quote_contract,
2745 anchors.token_in,
2746 anchors.token_out,
2747 amount_out,
2748 anchors.fee,
2749 anchors.state.number,
2750 )
2751 .await,
2752 "pre-sign exact-output quote reread",
2753 )?,
2754 };
2755 anyhow::ensure!(
2756 quote.value == anchors.quote,
2757 "Independent swap quote changed before signing"
2758 );
2759 Ok(vec![
2760 verification_decision(
2761 &checkpoint,
2762 Some(checkpoint.value.number),
2763 Some(checkpoint.value.number),
2764 ),
2765 verification_decision(
2766 &watermark,
2767 Some(anchors.watermark.number),
2768 Some(anchors.watermark.number),
2769 ),
2770 verification_decision(
2771 &ancestry,
2772 Some(anchors.watermark.number),
2773 Some(anchors.state.number),
2774 ),
2775 verification_decision(
2776 "e,
2777 Some(anchors.state.number),
2778 Some(anchors.state.number),
2779 ),
2780 ])
2781 }
2782
2783 async fn fill_and_persist(
2791 &self,
2792 prepared: &PreparedTransaction,
2793 purpose: TransactionPurpose,
2794 ) -> anyhow::Result<()> {
2795 {
2796 let mut slot = self.in_flight.lock();
2797 *slot = Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
2798 intent_id: prepared.intent_id,
2799 nonce: prepared.nonce,
2800 tx_hash: prepared.tx_hash,
2801 purpose,
2802 }));
2803 }
2804
2805 let tx_hash = prepared.tx_hash;
2806 let transaction_hash = tx_hash.to_string();
2807 let policy = self.payload_policy();
2808 let intent = self
2809 .database
2810 .get_execution_intent(prepared.intent_id)
2811 .await?;
2812 authenticate_payload_identity(
2813 &prepared.raw_tx,
2814 &intent,
2815 &transaction_hash,
2816 self.chain_id,
2817 policy,
2818 )
2819 .with_context(|| format!("Newly signed transaction {tx_hash} failed authentication"))?;
2820
2821 self.database
2822 .reserve_execution_payload_seal(&self.payload_keys)
2823 .await?;
2824 let context = payload_context_identity(
2825 &intent,
2826 &transaction_hash,
2827 self.chain_id,
2828 self.payload_keys.deployment_id(),
2829 )?;
2830 let envelope = self.payload_keys.seal(&prepared.raw_tx, &context)?;
2831 let row = self
2832 .database
2833 .add_execution_transaction_envelope(
2834 prepared.intent_id,
2835 self.chain_id,
2836 &transaction_hash,
2837 &envelope,
2838 )
2839 .await
2840 .map_err(|e| {
2841 anyhow::anyhow!(
2842 "Failed to persist transaction {tx_hash}: {e}; the in-flight slot stays occupied"
2843 )
2844 })?;
2845 let stored = open_execution_payload(
2846 &self.payload_keys,
2847 policy,
2848 &intent,
2849 &row,
2850 "initial persistence",
2851 )?;
2852 anyhow::ensure!(
2853 stored == prepared.raw_tx,
2854 "Persisted transaction {tx_hash} does not match the signed bytes"
2855 );
2856 Ok(())
2857 }
2858
2859 fn payload_policy(&self) -> PayloadPolicy {
2860 PayloadPolicy {
2861 chain_id: self.chain_id,
2862 signer: self.wallet_address,
2863 gas_limit: self.gas_limit,
2864 max_fee_per_gas: self.max_fee_per_gas_wei,
2865 }
2866 }
2867
2868 async fn authorize_rebroadcast(
2869 &self,
2870 prepared: &PreparedTransaction,
2871 intent: &ExecutionIntentRow,
2872 purpose: TransactionPurpose,
2873 ) -> anyhow::Result<ReconciliationAuthorization> {
2874 let now_unix_secs = current_unix_secs()?;
2875 let decision_header = required_verification(
2876 self.verification
2877 .verify_decision_header(now_unix_secs)
2878 .await,
2879 "rebroadcast decision header",
2880 )?;
2881 let block = decision_header.value.number;
2882 let mut decisions = vec![verification_decision(
2883 &decision_header,
2884 Some(block),
2885 Some(block),
2886 )];
2887 decisions.extend(self.verify_decision_ancestry(decision_header.value).await?);
2888 let deployment = required_verification(
2889 self.verification
2890 .verify_deployment_manifest(&self.deployment_manifest, block)
2891 .await,
2892 "rebroadcast deployment manifest",
2893 )?;
2894 decisions.push(verification_decision(&deployment, Some(block), Some(block)));
2895 let canonical_nonce = required_verification(
2896 self.verification
2897 .verify_transaction_count(&self.wallet_address, block)
2898 .await,
2899 "rebroadcast canonical nonce",
2900 )?;
2901 decisions.push(verification_decision(
2902 &canonical_nonce,
2903 Some(block),
2904 Some(block),
2905 ));
2906 let pending_nonce = required_verification(
2907 self.verification
2908 .verify_reconciliation_pending_transaction_count(
2909 &self.wallet_address,
2910 prepared.nonce,
2911 )
2912 .await,
2913 "rebroadcast pending nonce",
2914 )?;
2915 decisions.push(verification_decision(&pending_nonce, None, None));
2916 let receipt_absence = required_verification(
2917 self.verification
2918 .verify_receipt_absence(&prepared.tx_hash)
2919 .await,
2920 "rebroadcast receipt absence",
2921 )?;
2922 decisions.push(verification_decision(&receipt_absence, None, None));
2923
2924 let next_nonce = prepared
2925 .nonce
2926 .checked_add(1)
2927 .ok_or_else(|| anyhow::anyhow!("Owned signer nonce overflow"))?;
2928 anyhow::ensure!(
2929 (prepared.nonce..=next_nonce).contains(&pending_nonce.value),
2930 "Pending nonce {} is outside the owned reconciliation range {}..={next_nonce}",
2931 pending_nonce.value,
2932 prepared.nonce
2933 );
2934 anyhow::ensure!(
2935 (prepared.nonce..=next_nonce).contains(&canonical_nonce.value),
2936 "Canonical nonce {} is outside the owned reconciliation range {}..={next_nonce}",
2937 canonical_nonce.value,
2938 prepared.nonce
2939 );
2940
2941 if !receipt_absence.value {
2942 self.persist_rebroadcast_decisions(intent.id, prepared.nonce, &decisions)
2943 .await?;
2944 return Ok(ReconciliationAuthorization::Retain);
2945 }
2946
2947 if canonical_nonce.value == next_nonce {
2948 self.persist_rebroadcast_decisions(intent.id, prepared.nonce, &decisions)
2949 .await?;
2950 return Ok(ReconciliationAuthorization::ScanReplacement(
2951 decision_header.value,
2952 ));
2953 }
2954
2955 let (to, input, value) = persisted_call_fields(intent)?;
2956 let authorized = match purpose {
2957 TransactionPurpose::Wrap => {
2958 let simulation = required_verification(
2959 self.verification
2960 .verify_decoded_simulation(
2961 &self.wallet_address,
2962 &to,
2963 value,
2964 &input,
2965 block,
2966 |result| Ok(result.is_empty()),
2967 )
2968 .await,
2969 "rebroadcast wrap simulation",
2970 )?;
2971 let authorized = matches!(&simulation.value, VerifiedSimulation::Succeeded(true));
2972 decisions.push(verification_decision(&simulation, Some(block), Some(block)));
2973 authorized
2974 }
2975 TransactionPurpose::Approve => {
2976 let simulation = required_verification(
2977 self.verification
2978 .verify_decoded_simulation(
2979 &self.wallet_address,
2980 &to,
2981 value,
2982 &input,
2983 block,
2984 |result| {
2985 if result.is_empty() {
2986 Ok(true)
2987 } else {
2988 ERC20::approveCall::abi_decode_returns_validate(result)
2989 .map_err(Into::into)
2990 }
2991 },
2992 )
2993 .await,
2994 "rebroadcast approve simulation",
2995 )?;
2996 let authorized = matches!(&simulation.value, VerifiedSimulation::Succeeded(true));
2997 decisions.push(verification_decision(&simulation, Some(block), Some(block)));
2998 authorized
2999 }
3000 TransactionPurpose::Swap => {
3001 let call = UniswapV3SwapRouter::exactInputSingleCall::abi_decode(&input)
3002 .with_context(|| "persisted swap calldata is invalid")?;
3003
3004 if U256::from(decision_header.value.timestamp) > call.params.deadline {
3005 false
3006 } else {
3007 let simulation = required_verification(
3008 self.verification
3009 .verify_decoded_simulation(
3010 &self.wallet_address,
3011 &to,
3012 value,
3013 &input,
3014 block,
3015 |result| {
3016 UniswapV3SwapRouter::exactInputSingleCall::abi_decode_returns(
3017 result,
3018 )
3019 .map_err(Into::into)
3020 },
3021 )
3022 .await,
3023 "rebroadcast swap simulation",
3024 )?;
3025 let authorized = match &simulation.value {
3026 VerifiedSimulation::Succeeded(amount_out) => {
3027 *amount_out >= call.params.amountOutMinimum
3028 }
3029 VerifiedSimulation::Denied => false,
3030 };
3031 decisions.push(verification_decision(&simulation, Some(block), Some(block)));
3032 authorized
3033 }
3034 }
3035 };
3036 self.persist_rebroadcast_decisions(intent.id, prepared.nonce, &decisions)
3037 .await?;
3038 Ok(if authorized {
3039 ReconciliationAuthorization::Rebroadcast
3040 } else {
3041 ReconciliationAuthorization::Retain
3042 })
3043 }
3044
3045 async fn scan_canonical_replacement(
3046 &self,
3047 intent: &ExecutionIntentRow,
3048 nonce: u64,
3049 head: VerifiedBlockHeader,
3050 authenticated_payloads: &HashMap<B256, Vec<u8>>,
3051 ) -> anyhow::Result<Option<(B256, Vec<u8>)>> {
3052 let wallet_address = self.wallet_address.to_string();
3053 let cursor = self
3054 .database
3055 .load_execution_replacement_cursor(
3056 intent.id,
3057 self.chain_id,
3058 &wallet_address,
3059 nonce,
3060 &self.manifest_digest,
3061 )
3062 .await?;
3063 let start = cursor.as_ref().map_or(intent.created_block, |header| {
3064 header.number.saturating_add(1)
3065 });
3066 anyhow::ensure!(
3067 start <= head.number,
3068 "Canonical nonce advanced without an authenticated signer transaction in the scanned canonical range"
3069 );
3070 let scan_range = replacement_scan_range(start, head.number)?;
3071 let end = *scan_range.end();
3072 let mut decisions = Vec::new();
3073 let mut blocks = Vec::new();
3074
3075 if let Some(cursor) = cursor.as_ref() {
3076 let parent = parse_verified_header(cursor)?;
3077 let window = required_verification(
3078 self.verification
3079 .verify_replacement_window(parent, end)
3080 .await,
3081 "canonical replacement window",
3082 )?;
3083 decisions.push(verification_decision(&window, Some(start), Some(end)));
3084 blocks = window.value;
3085 } else {
3086 let start_header = required_verification(
3087 self.verification.verify_block(start).await,
3088 "canonical replacement start header",
3089 )?;
3090 decisions.push(verification_decision(
3091 &start_header,
3092 Some(start),
3093 Some(start),
3094 ));
3095 let start_block = required_verification(
3096 self.verification.verify_replacement_block(start).await,
3097 "canonical replacement start block",
3098 )?;
3099 anyhow::ensure!(
3100 VerifiedBlockHeader::from(start_block.value.clone()) == start_header.value,
3101 "Replacement block conflicts with its canonical header"
3102 );
3103 decisions.push(verification_decision(
3104 &start_block,
3105 Some(start),
3106 Some(start),
3107 ));
3108 blocks.push(start_block.value);
3109
3110 if end > start {
3111 let window = required_verification(
3112 self.verification
3113 .verify_replacement_window(start_header.value, end)
3114 .await,
3115 "canonical replacement window",
3116 )?;
3117 decisions.push(verification_decision(&window, Some(start + 1), Some(end)));
3118 blocks.extend(window.value);
3119 }
3120 }
3121
3122 let scanned_tip = blocks
3123 .last()
3124 .map(|block| VerifiedBlockHeader::from(block.clone()))
3125 .ok_or_else(|| anyhow::anyhow!("Verified replacement scan returned no blocks"))?;
3126 if end == head.number {
3127 anyhow::ensure!(
3128 scanned_tip == head,
3129 "Replacement scan tip conflicts with the verified canonical head"
3130 );
3131 }
3132 let mut candidates = blocks
3133 .iter()
3134 .flat_map(|block| block.transactions.iter())
3135 .filter(|transaction| {
3136 transaction.from == self.wallet_address && transaction.nonce == nonce
3137 });
3138 let candidate = candidates.next();
3139 anyhow::ensure!(
3140 candidates.next().is_none(),
3141 "Canonical replacement scan found duplicate signer-nonce transactions"
3142 );
3143
3144 let finalized_cursor = self
3145 .database
3146 .load_execution_verified_header(
3147 self.chain_id,
3148 &wallet_address,
3149 end,
3150 &self.manifest_digest,
3151 )
3152 .await?;
3153
3154 if let Some(cursor) = finalized_cursor.as_ref() {
3155 anyhow::ensure!(
3156 parse_verified_header(cursor)? == scanned_tip,
3157 "Replacement scan conflicts with the durable finalized header ledger"
3158 );
3159 }
3160
3161 let mut mismatch = None;
3162 let matched = candidate.and_then(|transaction| {
3163 let Some(raw_transaction) = authenticated_payloads.get(&transaction.hash).cloned()
3164 else {
3165 mismatch = Some(anyhow::anyhow!(
3166 "Canonical signer-nonce transaction {} has no authenticated retained payload",
3167 transaction.hash
3168 ));
3169 return None;
3170 };
3171
3172 if let Err(e) = validate_rpc_transaction_matches_payload(transaction, &raw_transaction)
3173 {
3174 mismatch = Some(e.context(format!(
3175 "Canonical signer-nonce transaction {} failed authenticated payload validation",
3176 transaction.hash
3177 )));
3178 return None;
3179 }
3180 Some((transaction.hash, raw_transaction))
3181 });
3182 let matched_hash = matched.as_ref().map(|(hash, _)| hash.to_string());
3183 self.database
3184 .record_execution_replacement_scan(&ExecutionReplacementScan {
3185 intent_id: intent.id,
3186 chain_id: self.chain_id,
3187 wallet_address: &wallet_address,
3188 nonce,
3189 finalized_cursor: finalized_cursor.as_ref(),
3190 matched_transaction_hash: matched_hash.as_deref(),
3191 manifest_version: &self.manifest_version,
3192 manifest_digest: &self.manifest_digest,
3193 provider_ids: &self.provider_ids,
3194 operator_ids: &self.operator_ids,
3195 failure_domain_ids: &self.failure_domain_ids,
3196 decisions: &decisions,
3197 })
3198 .await?;
3199
3200 if let Some(e) = mismatch {
3201 return Err(e);
3202 }
3203
3204 if matched.is_none() && end == head.number {
3205 anyhow::bail!(
3206 "Canonical nonce advanced without an authenticated signer transaction in the canonical range"
3207 );
3208 }
3209 Ok(matched)
3210 }
3211
3212 async fn persist_rebroadcast_decisions(
3213 &self,
3214 intent_id: i64,
3215 nonce: u64,
3216 decisions: &[ExecutionVerificationDecision],
3217 ) -> anyhow::Result<()> {
3218 let wallet_address = self.wallet_address.to_string();
3219 self.database
3220 .record_execution_verification_batch(&ExecutionVerificationBatch {
3221 intent_id,
3222 chain_id: self.chain_id,
3223 wallet_address: &wallet_address,
3224 nonce,
3225 decision_class: "rebroadcast",
3226 manifest_version: &self.manifest_version,
3227 manifest_digest: &self.manifest_digest,
3228 provider_ids: &self.provider_ids,
3229 operator_ids: &self.operator_ids,
3230 failure_domain_ids: &self.failure_domain_ids,
3231 decisions,
3232 })
3233 .await
3234 }
3235
3236 async fn broadcast(&self, prepared: &PreparedTransaction) -> anyhow::Result<BroadcastOutcome> {
3238 let tx_hash = prepared.tx_hash;
3239
3240 self.database
3241 .record_execution_status(
3242 prepared.intent_id,
3243 &tx_hash.to_string(),
3244 TransactionStatus::Broadcast,
3245 None,
3246 None,
3247 None,
3248 None,
3249 None,
3250 )
3251 .await
3252 .map_err(|e| {
3253 anyhow::anyhow!(
3254 "Failed to persist broadcast attempt for transaction {tx_hash}: {e}; the in-flight slot stays occupied"
3255 )
3256 })?;
3257
3258 match self
3259 .http_rpc_client
3260 .send_raw_transaction(&prepared.raw_tx, &tx_hash)
3261 .await
3262 {
3263 Ok(broadcast_hash) => {
3264 if broadcast_hash != tx_hash {
3265 return Ok(BroadcastOutcome::Ambiguous(format!(
3269 "Broadcast of transaction {tx_hash} returned a differing hash {broadcast_hash}; the persisted record reconciles instead of rebroadcasting"
3270 )));
3271 }
3272 Ok(BroadcastOutcome::Accepted)
3273 }
3274 Err(BroadcastError::TimeoutAfterSend) => Ok(BroadcastOutcome::Ambiguous(format!(
3275 "Broadcast of transaction {tx_hash} timed out after send; the persisted record reconciles instead of rebroadcasting"
3276 ))),
3277 Err(BroadcastError::Failed(message)) => {
3278 Ok(BroadcastOutcome::Ambiguous(format!(
3282 "Broadcast of transaction {tx_hash} failed ambiguously ({message}); the persisted record reconciles instead of rebroadcasting"
3283 )))
3284 }
3285 Err(error @ BroadcastError::Rejected { .. }) => {
3286 Ok(BroadcastOutcome::Ambiguous(format!(
3287 "Broadcast of transaction {tx_hash} was rejected ({error}); the signed hash remains occupied until canonical nonce reconciliation"
3288 )))
3289 }
3290 }
3291 }
3292
3293 async fn await_finality(
3295 &self,
3296 prepared: &PreparedTransaction,
3297 ) -> anyhow::Result<InclusionOutcome> {
3298 let tx_hash = prepared.tx_hash;
3299 let deadline = tokio::time::Instant::now() + self.receipt_timeout;
3300
3301 for attempt in 0..self.receipt_max_polls {
3302 if tokio::time::Instant::now() >= deadline {
3303 break;
3304 }
3305
3306 if attempt > 0 {
3307 tokio::time::sleep(RECEIPT_POLL_INTERVAL).await;
3308 }
3309
3310 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
3311 let receipt_result =
3312 match tokio::time::timeout(remaining, self.verification.verify_receipt(&tx_hash))
3313 .await
3314 {
3315 Ok(result) => result,
3316 Err(_) => break,
3317 };
3318
3319 match receipt_result {
3320 VerificationOutcome::Verified(verified_receipt) => {
3321 let receipt = &verified_receipt.value;
3322 let canonical_verification = required_verification(
3323 self.verification.verify_block(receipt.block_number).await,
3324 "receipt inclusion header",
3325 )?;
3326 let canonical = canonical_verification.value;
3327
3328 if canonical.hash != receipt.block_hash {
3329 continue;
3330 }
3331
3332 if let Some(mut finality) = self.receipt_is_stably_finalized(receipt).await? {
3333 finality.decisions.insert(
3334 0,
3335 verification_decision(
3336 &canonical_verification,
3337 Some(receipt.block_number),
3338 Some(receipt.block_number),
3339 ),
3340 );
3341 finality.decisions.insert(
3342 0,
3343 verification_decision(
3344 &verified_receipt,
3345 Some(receipt.block_number),
3346 Some(receipt.block_number),
3347 ),
3348 );
3349 let included = IncludedTransaction {
3350 intent_id: prepared.intent_id,
3351 nonce: prepared.nonce,
3352 tx_hash,
3353 block_number: receipt.block_number,
3354 receipt: receipt.clone(),
3355 finality,
3356 };
3357 return if receipt.status {
3358 Ok(InclusionOutcome::Finalized(included))
3359 } else {
3360 Ok(InclusionOutcome::Reverted(included))
3361 };
3362 }
3363 }
3364 VerificationOutcome::Retryable(_) => {
3365 continue;
3366 }
3367 VerificationOutcome::Disagreement(_) => {
3368 return Ok(InclusionOutcome::Pending(format!(
3369 "Receipt verification disagreed for transaction {tx_hash}; the intent stays occupied for reconciliation"
3370 )));
3371 }
3372 VerificationOutcome::Unavailable(_) => {
3373 log::warn!(
3374 "Finality poll {}/{} for transaction {tx_hash} was unavailable",
3375 attempt + 1,
3376 self.receipt_max_polls
3377 );
3378 }
3379 VerificationOutcome::LocallyInvalid(_) => {
3380 anyhow::bail!(
3381 "Receipt verification is locally invalid for transaction {tx_hash}"
3382 );
3383 }
3384 }
3385 }
3386
3387 self.database
3388 .record_execution_status(
3389 prepared.intent_id,
3390 &tx_hash.to_string(),
3391 TransactionStatus::Dropped,
3392 None,
3393 None,
3394 None,
3395 None,
3396 None,
3397 )
3398 .await?;
3399 Ok(InclusionOutcome::Pending(format!(
3400 "Timed out awaiting finality of transaction {tx_hash}; the intent stays occupied for reconciliation"
3401 )))
3402 }
3403
3404 async fn receipt_is_stably_finalized(
3405 &self,
3406 receipt: &RpcTransactionReceipt,
3407 ) -> anyhow::Result<Option<StableFinality>> {
3408 let finalized_verification = match self.verification.verify_finalized_header().await {
3409 VerificationOutcome::Verified(verified) => verified,
3410 VerificationOutcome::Retryable(_) | VerificationOutcome::Unavailable(_) => {
3411 return Ok(None);
3412 }
3413 VerificationOutcome::Disagreement(_) => {
3414 anyhow::bail!("Finalized header verification disagreed")
3415 }
3416 VerificationOutcome::LocallyInvalid(_) => {
3417 anyhow::bail!("Finalized header verification is locally invalid")
3418 }
3419 };
3420 let finalized = finalized_verification.value;
3421 if finalized.number < receipt.block_number {
3422 return Ok(None);
3423 }
3424
3425 let checkpoint_verification = required_verification(
3426 self.verification.verify_checkpoint().await,
3427 "finality checkpoint reread",
3428 )?;
3429 let checkpoint = checkpoint_verification.value;
3430 let mut decisions = vec![verification_decision(
3431 &checkpoint_verification,
3432 Some(checkpoint.number),
3433 Some(checkpoint.number),
3434 )];
3435 let position = self
3436 .database
3437 .load_execution_verification_position(
3438 self.chain_id,
3439 &self.wallet_address.to_string(),
3440 &self.manifest_version,
3441 &self.manifest_digest,
3442 )
3443 .await?
3444 .ok_or_else(|| anyhow::anyhow!("Execution verification ledger is not initialized"))?;
3445 let durable_tip = parse_verified_header(&position.finalized_tip)?;
3446 anyhow::ensure!(
3447 durable_tip.number >= checkpoint.number,
3448 "Durable finalized header tip precedes the trusted checkpoint"
3449 );
3450 let mut finalized_headers = vec![durable_tip];
3451 let durable_tip_verification = required_verification(
3452 self.verification.verify_block(durable_tip.number).await,
3453 "finality durable header tip",
3454 )?;
3455 anyhow::ensure!(
3456 durable_tip_verification.value == durable_tip,
3457 "Durable finalized header tip conflicts with independent sources"
3458 );
3459 decisions.push(verification_decision(
3460 &durable_tip_verification,
3461 Some(durable_tip.number),
3462 Some(durable_tip.number),
3463 ));
3464 anyhow::ensure!(
3465 finalized.number >= durable_tip.number,
3466 "Verified finalized height regressed below the durable finalized header tip"
3467 );
3468 let mut ancestry_cursor = durable_tip;
3469 while ancestry_cursor.number < finalized.number {
3470 let end = ancestry_cursor
3471 .number
3472 .saturating_add(4_096)
3473 .min(finalized.number);
3474 let start = ancestry_cursor.number.saturating_add(1);
3475 let ancestry_verification = required_verification(
3476 self.verification
3477 .verify_header_window(ancestry_cursor, end)
3478 .await,
3479 "finality ancestry",
3480 )?;
3481 let ancestry = &ancestry_verification.value;
3482 ancestry_cursor = *ancestry
3483 .last()
3484 .expect("nonempty finality ancestry advances the cursor");
3485 decisions.push(verification_decision(
3486 &ancestry_verification,
3487 Some(start),
3488 Some(end),
3489 ));
3490 finalized_headers.extend(ancestry.iter().copied());
3491 }
3492 anyhow::ensure!(
3493 ancestry_cursor == finalized,
3494 "Finalized header conflicts with its verified ancestry"
3495 );
3496
3497 let canonical_again_verification = required_verification(
3498 self.verification.verify_block(receipt.block_number).await,
3499 "finality inclusion header reread",
3500 )?;
3501 let canonical_again = canonical_again_verification.value;
3502 let finalized_again_verification = required_verification(
3503 self.verification.verify_block(finalized.number).await,
3504 "finalized header reread",
3505 )?;
3506 let finalized_again = finalized_again_verification.value;
3507 anyhow::ensure!(
3508 canonical_again.hash == receipt.block_hash && finalized_again == finalized,
3509 "Finality verification disagreed with the receipt or finalized header"
3510 );
3511 decisions.extend([
3512 verification_decision(
3513 &finalized_verification,
3514 Some(finalized.number),
3515 Some(finalized.number),
3516 ),
3517 verification_decision(
3518 &canonical_again_verification,
3519 Some(receipt.block_number),
3520 Some(receipt.block_number),
3521 ),
3522 verification_decision(
3523 &finalized_again_verification,
3524 Some(finalized.number),
3525 Some(finalized.number),
3526 ),
3527 ]);
3528 Ok(Some(StableFinality {
3529 decisions,
3530 inclusion_header: durable_verified_header(&canonical_again),
3531 finalized_headers: finalized_headers
3532 .iter()
3533 .map(durable_verified_header)
3534 .collect(),
3535 }))
3536 }
3537
3538 async fn commit_verified_finality(
3539 &self,
3540 included: &IncludedTransaction,
3541 status: TransactionStatus,
3542 verified_postconditions: &[ExecutionVerificationDecision],
3543 ) -> anyhow::Result<()> {
3544 anyhow::ensure!(
3545 matches!(
3546 status,
3547 TransactionStatus::Finalized | TransactionStatus::Reverted
3548 ) && included.receipt.status == (status == TransactionStatus::Finalized),
3549 "Finality status conflicts with the verified transaction receipt"
3550 );
3551 let mut decisions = included.finality.decisions.clone();
3552 decisions.extend_from_slice(verified_postconditions);
3553 let wallet_address = self.wallet_address.to_string();
3554 let transaction_hash = included.tx_hash.to_string();
3555 let block_hash = included.receipt.block_hash.to_string();
3556 let effective_gas_price = included.receipt.effective_gas_price.to_string();
3557 self.database
3558 .record_execution_finality_verified(&ExecutionFinalityTransition {
3559 intent_id: included.intent_id,
3560 chain_id: self.chain_id,
3561 wallet_address: &wallet_address,
3562 nonce: included.nonce,
3563 transaction_hash: &transaction_hash,
3564 status,
3565 block_number: included.block_number,
3566 block_hash: &block_hash,
3567 receipt_success: included.receipt.status,
3568 gas_used: included.receipt.gas_used,
3569 effective_gas_price: &effective_gas_price,
3570 manifest_version: &self.manifest_version,
3571 manifest_digest: &self.manifest_digest,
3572 provider_ids: &self.provider_ids,
3573 operator_ids: &self.operator_ids,
3574 failure_domain_ids: &self.failure_domain_ids,
3575 decisions: &decisions,
3576 finalized_headers: &included.finality.finalized_headers,
3577 })
3578 .await
3579 }
3580
3581 fn release_slot(&self) {
3582 *self.in_flight.lock() = None;
3583 }
3584}
3585
3586fn replacement_scan_range(from_block: u64, head_block: u64) -> anyhow::Result<RangeInclusive<u64>> {
3587 anyhow::ensure!(
3588 head_block >= from_block,
3589 "Canonical head {head_block} is behind execution creation block {from_block}"
3590 );
3591 let max_end = from_block.saturating_add(MAX_REPLACEMENT_SCAN_BLOCKS - 1);
3592 Ok(from_block..=head_block.min(max_end))
3593}
3594
3595fn current_unix_secs() -> anyhow::Result<u64> {
3596 SystemTime::now()
3597 .duration_since(UNIX_EPOCH)
3598 .map_err(|_| anyhow::anyhow!("Trusted host clock precedes the Unix epoch"))
3599 .map(|duration| duration.as_secs())
3600}
3601
3602fn validate_payload_operation_batch_size(batch_size: usize) -> anyhow::Result<i64> {
3603 anyhow::ensure!(
3604 (1..=MAX_PAYLOAD_OPERATION_BATCH_SIZE).contains(&batch_size),
3605 "Payload operation batch size must be between 1 and {MAX_PAYLOAD_OPERATION_BATCH_SIZE}"
3606 );
3607 Ok(i64::try_from(batch_size).expect("bounded payload batch size fits i64"))
3608}
3609
3610fn current_execution_hash(
3611 intent_id: i64,
3612 hashes: &[ExecutionTransactionHashRow],
3613) -> anyhow::Result<&ExecutionTransactionHashRow> {
3614 let mut current = hashes.iter().filter(|row| row.current);
3615 let row = current
3616 .next()
3617 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} has no current hash"))?;
3618 anyhow::ensure!(
3619 current.next().is_none(),
3620 "Execution intent {intent_id} has more than one current hash"
3621 );
3622 Ok(row)
3623}
3624
3625fn open_execution_payload(
3626 keys: &PayloadKeySet,
3627 policy: PayloadPolicy,
3628 intent: &ExecutionIntentRow,
3629 hash: &ExecutionTransactionHashRow,
3630 reason: &str,
3631) -> anyhow::Result<Vec<u8>> {
3632 anyhow::ensure!(
3633 hash.payload_expected,
3634 "Execution transaction {} has no signed payload",
3635 hash.transaction_hash
3636 );
3637 anyhow::ensure!(
3638 hash.raw_transaction.is_none(),
3639 "Protected execution transaction {} contains plaintext",
3640 hash.transaction_hash
3641 );
3642 let envelope = hash.sealed_transaction.as_deref().ok_or_else(|| {
3643 anyhow::anyhow!(
3644 "Protected execution transaction {} has no sealed payload",
3645 hash.transaction_hash
3646 )
3647 })?;
3648 let context = payload_context(intent, hash, keys.deployment_id())?;
3649 let raw_transaction = keys.unseal(envelope, &context)?;
3650 log::info!(
3651 "Unsealed execution payload for intent {} transaction {} during {reason}",
3652 intent.id,
3653 hash.transaction_hash
3654 );
3655 authenticate_retained_payload(&raw_transaction, intent, hash, keys.deployment_id())?;
3656 if retained_payload_requires_policy(intent, hash, policy)? {
3657 authenticate_payload(&raw_transaction, intent, hash, policy, keys.deployment_id())
3658 .with_context(|| {
3659 format!(
3660 "execution intent {} transaction {} violates current execution policy",
3661 intent.id, hash.transaction_hash
3662 )
3663 })?;
3664 }
3665 Ok(raw_transaction)
3666}
3667
3668fn receipt_max_polls(receipt_timeout_secs: u64) -> u32 {
3670 u32::try_from(receipt_timeout_secs.max(1)).unwrap_or(u32::MAX)
3671}
3672
3673fn receipt_timeout(receipt_timeout_secs: u64) -> Duration {
3674 Duration::from_secs(receipt_timeout_secs.clamp(1, u64::from(u32::MAX)))
3675}
3676
3677#[derive(Debug)]
3679struct SwapPlan {
3680 order: OrderAny,
3681 pool: Pool,
3682 quote_currency: Currency,
3683 instrument_id: InstrumentId,
3684 pool_address: Address,
3685 router: Address,
3686 factory: Address,
3687 weth: Address,
3688 token_in: Address,
3689 token_out: Address,
3690 fee: U24,
3691 amount_in: U256,
3692 min_amount_out: U256,
3693 slippage_bps: u32,
3694 quote_spend_ceiling: Option<QuoteSpendCeiling>,
3695 profiler_position: Option<BlockPosition>,
3696}
3697
3698#[derive(Debug, Clone)]
3699struct SwapQuoteAnchors {
3700 watermark: VerifiedBlockHeader,
3701 state: VerifiedBlockHeader,
3702 quote_contract: Address,
3703 token_in: Address,
3704 token_out: Address,
3705 fee: U24,
3706 quote_kind: SwapQuoteKind,
3707 quote: UniswapV3Quote,
3708 precondition_decisions: Vec<ExecutionVerificationDecision>,
3709}
3710
3711#[derive(Debug, Clone, Copy)]
3712enum SwapQuoteKind {
3713 ExactInput(U256),
3714 ExactOutput(U256),
3715}
3716
3717async fn execute_swap(
3726 mut plan: SwapPlan,
3727 executor: TransactionExecutor,
3728 emitter: ExecutionEventEmitter,
3729 max_quote_age_blocks: u64,
3730 deadline_seconds: u64,
3731) -> anyhow::Result<()> {
3732 let order = &plan.order;
3733
3734 if let Err(e) = executor.claim_slot(TransactionPurpose::Swap) {
3735 emitter.emit_order_denied(order, &e.to_string());
3736 return Ok(());
3737 }
3738
3739 let Some(profiler_position) = plan.profiler_position.as_ref() else {
3740 release_preparing_slot(&executor.in_flight);
3741 emitter.emit_order_denied(order, "Pool profiler has no quote provenance");
3742 return Ok(());
3743 };
3744 let mut swap_anchors = match validate_swap_quote(
3745 profiler_position,
3746 &plan,
3747 max_quote_age_blocks,
3748 &executor.verification,
3749 &executor.deployment_manifest,
3750 )
3751 .await
3752 {
3753 Ok(anchors) => anchors,
3754 Err(e) => {
3755 release_preparing_slot(&executor.in_flight);
3756 emitter.emit_order_denied(order, &e.to_string());
3757 return Ok(());
3758 }
3759 };
3760 let (amount_in, min_amount_out) = match verified_swap_amounts(&plan, swap_anchors.quote) {
3761 Ok(amounts) => amounts,
3762 Err(e) => {
3763 release_preparing_slot(&executor.in_flight);
3764 emitter.emit_order_denied(order, &e.to_string());
3765 return Ok(());
3766 }
3767 };
3768 plan.amount_in = amount_in;
3769 plan.min_amount_out = min_amount_out;
3770 let deadline = match swap_anchors.state.timestamp.checked_add(deadline_seconds) {
3771 Some(deadline) => deadline,
3772 None => {
3773 release_preparing_slot(&executor.in_flight);
3774 emitter.emit_order_denied(
3775 order,
3776 &format!(
3777 "Swap deadline overflow: anchor timestamp {} plus `deadline_seconds` {deadline_seconds} exceeds u64",
3778 swap_anchors.state.timestamp
3779 ),
3780 );
3781 return Ok(());
3782 }
3783 };
3784
3785 swap_anchors.precondition_decisions =
3786 match check_swap_preconditions(&plan, swap_anchors.state.number, &executor).await {
3787 Ok(decisions) => decisions,
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
3795 let calldata = UniswapV3SwapRouter::exactInputSingleCall {
3796 params: UniswapV3SwapRouter::ExactInputSingleParams {
3797 tokenIn: plan.token_in,
3798 tokenOut: plan.token_out,
3799 fee: plan.fee,
3800 recipient: executor.wallet_address,
3801 deadline: U256::from(deadline),
3802 amountIn: plan.amount_in,
3803 amountOutMinimum: plan.min_amount_out,
3804 sqrtPriceLimitX96: U160::ZERO,
3805 },
3806 }
3807 .abi_encode();
3808
3809 let calldata = Bytes::from(calldata);
3810 let intent = ExecutionIntentInsert {
3811 chain_id: executor.chain_id,
3812 wallet_address: executor.wallet_address.to_string(),
3813 purpose: TransactionPurpose::Swap.as_str().to_string(),
3814 client_order_id: Some(order.client_order_id().to_string()),
3815 trader_id: Some(order.trader_id().to_string()),
3816 strategy_id: Some(order.strategy_id().to_string()),
3817 account_id: Some(executor.account_id.to_string()),
3818 instrument_id: Some(plan.instrument_id.to_string()),
3819 pool_address: Some(plan.pool_address.to_string()),
3820 transaction_to: plan.router.to_string(),
3821 transaction_input: hex::encode_prefixed(&calldata),
3822 transaction_value: U256::ZERO.to_string(),
3823 amount_in: Some(plan.amount_in.to_string()),
3824 created_block: swap_anchors.state.number,
3825 };
3826 let intent = match executor.database.reserve_execution_intent(&intent).await {
3827 Ok(intent) => intent,
3828 Err(e) => {
3829 release_preparing_if_reservation_not_committed(&executor.in_flight, &e);
3830 emitter.emit_order_denied(order, &e.to_string());
3831 return Ok(());
3832 }
3833 };
3834 let prepared = match executor
3835 .prepare_and_sign_swap(
3836 intent.id,
3837 intent.created_block,
3838 plan.router,
3839 U256::ZERO,
3840 calldata,
3841 &swap_anchors,
3842 )
3843 .await
3844 {
3845 Ok(prepared) => prepared,
3846 Err(e) => {
3847 if executor
3848 .database
3849 .mark_execution_intent_recoverable(intent.id)
3850 .await
3851 .is_ok()
3852 {
3853 release_preparing_slot(&executor.in_flight);
3854 }
3855 emitter.emit_order_denied(order, &e.to_string());
3856 return Ok(());
3857 }
3858 };
3859
3860 if let Err(e) = executor
3861 .fill_and_persist(&prepared, TransactionPurpose::Swap)
3862 .await
3863 {
3864 emitter.emit_order_denied(order, &e.to_string());
3865 return Ok(());
3866 }
3867
3868 let broadcast = executor.broadcast(&prepared).await?;
3869 emitter.emit_order_submitted(order);
3870 executor
3871 .database
3872 .mark_execution_event_emitted(intent.id, "acknowledgement")
3873 .await?;
3874
3875 if let BroadcastOutcome::Ambiguous(message) = broadcast {
3876 log::warn!("{message}");
3877 }
3878
3879 let trace_purpose = match plan.order.order_side() {
3880 OrderSide::Sell => "swap_sell",
3881 OrderSide::Buy => "swap_buy",
3882 };
3883
3884 match executor.await_finality(&prepared).await? {
3885 InclusionOutcome::Finalized(mut included) => {
3886 included.finality.decisions.extend(
3887 verify_finalized_transaction(
3888 &included,
3889 &intent,
3890 prepared.nonce,
3891 &prepared.raw_tx,
3892 &executor,
3893 trace_purpose,
3894 )
3895 .await?,
3896 );
3897 let fill = validate_finalized_swap_fill(&plan, &included)?;
3898 let wallet = load_verified_wallet_after_fill(&plan, &included, &executor).await?;
3899 executor
3900 .commit_verified_finality(
3901 &included,
3902 TransactionStatus::Finalized,
3903 &wallet.decisions,
3904 )
3905 .await?;
3906 complete_finalized_swap(
3907 &plan,
3908 intent.id,
3909 included.tx_hash,
3910 fill,
3911 wallet,
3912 &executor,
3913 &emitter,
3914 )
3915 .await?;
3916 executor.release_slot();
3917 Ok(())
3918 }
3919 InclusionOutcome::Reverted(mut included) => {
3920 included.finality.decisions.extend(
3921 verify_finalized_transaction(
3922 &included,
3923 &intent,
3924 prepared.nonce,
3925 &prepared.raw_tx,
3926 &executor,
3927 trace_purpose,
3928 )
3929 .await?,
3930 );
3931 executor
3932 .commit_verified_finality(&included, TransactionStatus::Reverted, &[])
3933 .await?;
3934 send_reverted_order(&emitter, order, &included)?;
3935 executor
3936 .database
3937 .mark_execution_event_emitted(intent.id, "terminal")
3938 .await?;
3939 executor.release_slot();
3940 Ok(())
3941 }
3942 InclusionOutcome::Pending(message) => anyhow::bail!(message),
3943 }
3944}
3945
3946async fn validate_swap_quote(
3947 position: &BlockPosition,
3948 plan: &SwapPlan,
3949 max_age_blocks: u64,
3950 verification: &VerificationCoordinator,
3951 manifest: &BlockchainDeploymentManifest,
3952) -> anyhow::Result<SwapQuoteAnchors> {
3953 let block_hash = position.block_hash.as_deref().ok_or_else(|| {
3954 anyhow::anyhow!(
3955 "Pool state at block {} has no ingestion-time block hash; refresh the profiler before execution",
3956 position.number
3957 )
3958 })?;
3959 let expected_block_hash = B256::from_str(block_hash)
3960 .with_context(|| format!("Invalid profiler block hash {block_hash}"))?;
3961 let now_unix_secs = current_unix_secs()?;
3962 let head = verified_value(
3963 verification.verify_decision_header(now_unix_secs).await,
3964 "swap decision header",
3965 )?;
3966 validate_quote_age(position.number, head.number, max_age_blocks)?;
3967 let canonical_block = verified_value(
3968 verification.verify_block(position.number).await,
3969 "profiler watermark header",
3970 )?;
3971 anyhow::ensure!(
3972 canonical_block.hash == expected_block_hash,
3973 "Pool state block {} changed from {} to {}; refresh the profiler before execution",
3974 position.number,
3975 expected_block_hash,
3976 canonical_block.hash
3977 );
3978
3979 let snapshot_transaction = position.transaction_index == BLOCK_SCOPED_SNAPSHOT_INDEX;
3980 let snapshot_log = position.log_index == BLOCK_SCOPED_SNAPSHOT_INDEX;
3981 anyhow::ensure!(
3982 snapshot_transaction == snapshot_log,
3983 "Pool state at block {} has an invalid partial snapshot watermark",
3984 position.number
3985 );
3986
3987 if snapshot_transaction {
3988 let snapshot_hash = B256::from_str(&position.transaction_hash)
3989 .with_context(|| "Invalid block-scoped snapshot hash")?;
3990 anyhow::ensure!(
3991 snapshot_hash == expected_block_hash,
3992 "Block-scoped snapshot hash {snapshot_hash} does not match ingestion hash {expected_block_hash}"
3993 );
3994 } else {
3995 validate_profiler_event_verified(
3996 position,
3997 expected_block_hash,
3998 plan.pool_address,
3999 &plan.pool,
4000 verification,
4001 )
4002 .await?;
4003 }
4004
4005 let ancestry = verified_value(
4006 verification
4007 .verify_header_window(canonical_block, head.number)
4008 .await,
4009 "profiler-to-decision ancestry",
4010 )?;
4011
4012 if let Some(last) = ancestry.last() {
4013 anyhow::ensure!(
4014 *last == head,
4015 "Swap decision header conflicts with profiler ancestry"
4016 );
4017 } else {
4018 anyhow::ensure!(
4019 canonical_block == head,
4020 "Empty profiler ancestry does not end at the decision header"
4021 );
4022 }
4023 verified_value(
4024 verification
4025 .verify_deployment_manifest(manifest, head.number)
4026 .await,
4027 "swap deployment manifest",
4028 )?;
4029 let quote_contract = validate_manifest_pool(plan, manifest)?;
4030 let quote_kind = match plan.order.order_side() {
4031 OrderSide::Sell => SwapQuoteKind::ExactInput(quantity_to_raw_amount(
4032 plan.order.quantity(),
4033 plan.pool.get_base_token().decimals,
4034 )?),
4035 OrderSide::Buy => SwapQuoteKind::ExactOutput(quantity_to_raw_amount(
4036 plan.order.quantity(),
4037 plan.pool.get_base_token().decimals,
4038 )?),
4039 };
4040 let quote = verified_value(
4041 verify_swap_quote(verification, quote_contract, plan, quote_kind, head.number).await,
4042 "independent swap quote",
4043 )?;
4044
4045 Ok(SwapQuoteAnchors {
4046 watermark: canonical_block,
4047 state: head,
4048 quote_contract,
4049 token_in: plan.token_in,
4050 token_out: plan.token_out,
4051 fee: plan.fee,
4052 quote_kind,
4053 quote,
4054 precondition_decisions: Vec::new(),
4055 })
4056}
4057
4058async fn verify_swap_quote(
4059 verification: &VerificationCoordinator,
4060 quote_contract: Address,
4061 plan: &SwapPlan,
4062 kind: SwapQuoteKind,
4063 block: u64,
4064) -> VerificationOutcome<UniswapV3Quote> {
4065 match kind {
4066 SwapQuoteKind::ExactInput(amount_in) => {
4067 verification
4068 .verify_quote_exact_input_single(
4069 "e_contract,
4070 plan.token_in,
4071 plan.token_out,
4072 amount_in,
4073 plan.fee,
4074 block,
4075 )
4076 .await
4077 }
4078 SwapQuoteKind::ExactOutput(amount_out) => {
4079 verification
4080 .verify_quote_exact_output_single(
4081 "e_contract,
4082 plan.token_in,
4083 plan.token_out,
4084 amount_out,
4085 plan.fee,
4086 block,
4087 )
4088 .await
4089 }
4090 }
4091}
4092
4093fn verified_swap_amounts(plan: &SwapPlan, quote: UniswapV3Quote) -> anyhow::Result<(U256, U256)> {
4094 anyhow::ensure!(
4095 !quote.amount.is_zero(),
4096 "Independent swap quote returned zero"
4097 );
4098 let base_amount =
4099 quantity_to_raw_amount(plan.order.quantity(), plan.pool.get_base_token().decimals)?;
4100 let slippage_bps = plan.slippage_bps;
4101 match plan.order.order_side() {
4102 OrderSide::Sell => Ok((
4103 base_amount,
4104 derive_min_amount_out(quote.amount, slippage_bps)?,
4105 )),
4106 OrderSide::Buy => {
4107 let ceiling = plan.quote_spend_ceiling.ok_or_else(|| {
4108 anyhow::anyhow!(
4109 "No quote spend ceiling for BUY token pair {} -> {}",
4110 plan.token_in,
4111 plan.token_out
4112 )
4113 })?;
4114 anyhow::ensure!(
4115 quote.amount <= ceiling.max_amount,
4116 "BUY quote amount {} exceeds the configured quote-spend maximum {} for {} -> {}",
4117 quote.amount,
4118 ceiling.max_amount,
4119 plan.token_in,
4120 plan.token_out
4121 );
4122 Ok((
4123 quote.amount,
4124 derive_min_amount_out(base_amount, slippage_bps)?,
4125 ))
4126 }
4127 }
4128}
4129
4130fn validate_manifest_pool(
4131 plan: &SwapPlan,
4132 manifest: &BlockchainDeploymentManifest,
4133) -> anyhow::Result<Address> {
4134 let matching = manifest
4135 .pools
4136 .iter()
4137 .filter(|pool| Address::from_str(&pool.address).ok() == Some(plan.pool_address))
4138 .collect::<Vec<_>>();
4139 anyhow::ensure!(
4140 matching.len() == 1,
4141 "Pool {} does not have exactly one deployment manifest definition",
4142 plan.pool_address
4143 );
4144 let pool = matching[0];
4145 let token0 = Address::from_str(&pool.token0)?;
4146 let token1 = Address::from_str(&pool.token1)?;
4147 let factory = Address::from_str(&pool.factory)?;
4148 let quote_contract = Address::from_str(&pool.quote_contract)?;
4149 anyhow::ensure!(
4150 token0 == plan.pool.token0.address
4151 && token1 == plan.pool.token1.address
4152 && pool.fee == plan.pool.fee.expect("validated pool fee")
4153 && factory == plan.factory,
4154 "Cached pool {} does not match its deployment manifest identity",
4155 plan.pool_address
4156 );
4157
4158 for token in [&plan.pool.token0, &plan.pool.token1] {
4159 let identities = manifest
4160 .tokens
4161 .iter()
4162 .filter(|identity| Address::from_str(&identity.address).ok() == Some(token.address))
4163 .collect::<Vec<_>>();
4164 anyhow::ensure!(
4165 identities.len() == 1,
4166 "Token {} does not have exactly one deployment manifest identity",
4167 token.address
4168 );
4169 let identity = identities[0];
4170 anyhow::ensure!(
4171 identity.name == token.name
4172 && identity.symbol == token.symbol
4173 && identity.decimals == token.decimals,
4174 "Cached token {} does not match its deployment manifest identity",
4175 token.address
4176 );
4177 let expected_role = if token.address == plan.pool.get_base_token().address {
4178 "base"
4179 } else {
4180 "quote"
4181 };
4182 anyhow::ensure!(
4183 matches!(identity.asset_role.as_str(), "both") || identity.asset_role == expected_role,
4184 "Token {} is not permitted as the pool {expected_role} asset",
4185 token.address
4186 );
4187 }
4188 Ok(quote_contract)
4189}
4190
4191async fn validate_profiler_event_verified(
4192 position: &BlockPosition,
4193 expected_block_hash: B256,
4194 pool_address: Address,
4195 pool: &Pool,
4196 verification: &VerificationCoordinator,
4197) -> anyhow::Result<()> {
4198 let transaction_hash = B256::from_str(&position.transaction_hash).with_context(|| {
4199 format!(
4200 "Invalid profiler transaction hash {}",
4201 position.transaction_hash
4202 )
4203 })?;
4204 let receipt = verified_value(
4205 verification.verify_receipt(&transaction_hash).await,
4206 "profiler watermark receipt",
4207 )?;
4208 anyhow::ensure!(
4209 receipt.status,
4210 "Profiler transaction did not execute successfully"
4211 );
4212 anyhow::ensure!(
4213 receipt.transaction_hash == transaction_hash,
4214 "Profiler receipt transaction hash does not match its ingestion watermark"
4215 );
4216 anyhow::ensure!(
4217 receipt.block_number == position.number
4218 && receipt.block_hash == expected_block_hash
4219 && receipt.transaction_index == u64::from(position.transaction_index),
4220 "Profiler receipt position does not match its ingestion watermark"
4221 );
4222 let matching_logs = receipt
4223 .logs
4224 .iter()
4225 .filter(|log| rpc_helpers::extract_log_index(log).ok() == Some(position.log_index))
4226 .collect::<Vec<_>>();
4227 anyhow::ensure!(
4228 matching_logs.len() == 1,
4229 "Profiler receipt contains {} logs at global index {}; expected exactly one",
4230 matching_logs.len(),
4231 position.log_index
4232 );
4233 let log = matching_logs[0];
4234 let log_transaction_hash = B256::from_str(&rpc_helpers::extract_transaction_hash(log)?)
4235 .with_context(|| "Invalid profiler log transaction hash")?;
4236 let log_block_hash = log
4237 .block_hash
4238 .as_deref()
4239 .ok_or_else(|| anyhow::anyhow!("Profiler log has no block hash"))?;
4240 anyhow::ensure!(
4241 !log.removed
4242 && log_transaction_hash == transaction_hash
4243 && rpc_helpers::extract_block_number(log)? == position.number
4244 && rpc_helpers::extract_transaction_index(log)? == position.transaction_index
4245 && B256::from_str(log_block_hash)? == expected_block_hash,
4246 "Profiler log position does not match its ingestion watermark"
4247 );
4248 anyhow::ensure!(
4249 rpc_helpers::extract_address(log)? == pool_address,
4250 "Profiler watermark log did not come from expected pool {pool_address}"
4251 );
4252 let signature = log
4253 .topics
4254 .first()
4255 .ok_or_else(|| anyhow::anyhow!("Profiler watermark log has no event signature"))?;
4256 let supported =
4257 profiler_event_signatures(pool).any(|expected| expected.eq_ignore_ascii_case(signature));
4258 anyhow::ensure!(
4259 supported,
4260 "Profiler watermark log has an unsupported event signature"
4261 );
4262 Ok(())
4263}
4264
4265fn profiler_event_signatures(pool: &Pool) -> impl Iterator<Item = &str> {
4266 [
4267 Some(pool.dex.swap_created_event.as_ref()),
4268 Some(pool.dex.mint_created_event.as_ref()),
4269 Some(pool.dex.burn_created_event.as_ref()),
4270 Some(pool.dex.collect_created_event.as_ref()),
4271 pool.dex.flash_created_event.as_deref(),
4272 pool.dex.fee_protocol_update_event.as_deref(),
4273 pool.dex.fee_protocol_collect_event.as_deref(),
4274 ]
4275 .into_iter()
4276 .flatten()
4277}
4278
4279fn validate_quote_age(
4280 profiler_block: u64,
4281 latest_block: u64,
4282 max_age_blocks: u64,
4283) -> anyhow::Result<()> {
4284 anyhow::ensure!(
4285 profiler_block <= latest_block,
4286 "Pool state at block {profiler_block} is ahead of the latest block {latest_block}; the execution RPC endpoint lags the data feed"
4287 );
4288 let quote_age = latest_block - profiler_block;
4289 anyhow::ensure!(
4290 quote_age <= max_age_blocks,
4291 "Stale quote: pool state at block {profiler_block}, latest block {latest_block}, exceeds `max_quote_age_blocks` {max_age_blocks}"
4292 );
4293 Ok(())
4294}
4295
4296fn validate_rpc_transaction_matches_payload(
4297 transaction: &RpcTransaction,
4298 raw_transaction: &[u8],
4299) -> anyhow::Result<()> {
4300 let signed = decode_signed_transaction(raw_transaction)?;
4301 anyhow::ensure!(
4302 transaction.hash == signed.hash
4303 && transaction.from == signed.signer
4304 && transaction.nonce == signed.nonce
4305 && transaction.chain_id == Some(signed.chain_id)
4306 && transaction.transaction_type == Some(2)
4307 && transaction.to == Some(signed.to)
4308 && transaction.input == signed.input
4309 && transaction.value == signed.value
4310 && transaction.gas == Some(signed.gas_limit)
4311 && transaction.max_fee_per_gas == Some(U256::from(signed.max_fee_per_gas))
4312 && transaction.max_priority_fee_per_gas
4313 == Some(U256::from(signed.max_priority_fee_per_gas)),
4314 "Verified transaction fields differ from the authenticated signed payload"
4315 );
4316 Ok(())
4317}
4318
4319async fn verify_finalized_transaction(
4320 included: &IncludedTransaction,
4321 intent: &ExecutionIntentRow,
4322 nonce: u64,
4323 raw_transaction: &[u8],
4324 executor: &TransactionExecutor,
4325 trace_purpose: &str,
4326) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4327 verify_finalized_transaction_identity(
4328 included,
4329 intent,
4330 nonce,
4331 raw_transaction,
4332 &executor.verification,
4333 executor.wallet_address,
4334 executor.chain_id,
4335 &executor.deployment_manifest,
4336 trace_purpose,
4337 )
4338 .await
4339}
4340
4341#[expect(clippy::too_many_arguments)]
4342async fn verify_finalized_transaction_identity(
4343 included: &IncludedTransaction,
4344 intent: &ExecutionIntentRow,
4345 nonce: u64,
4346 raw_transaction: &[u8],
4347 verification: &VerificationCoordinator,
4348 wallet_address: Address,
4349 chain_id: u32,
4350 deployment_manifest: &BlockchainDeploymentManifest,
4351 trace_purpose: &str,
4352) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4353 let transaction_verification = required_verification(
4354 verification.verify_transaction(&included.tx_hash).await,
4355 "finalized transaction",
4356 )?;
4357 let transaction = &transaction_verification.value;
4358 let signed = decode_signed_transaction(raw_transaction)?;
4359 let (expected_to, expected_input, expected_value) = persisted_call_fields(intent)?;
4360 anyhow::ensure!(
4361 included.receipt.transaction_hash == included.tx_hash
4362 && signed.hash == included.tx_hash
4363 && signed.signer == wallet_address
4364 && signed.chain_id == u64::from(chain_id)
4365 && signed.nonce == nonce
4366 && signed.to == expected_to
4367 && signed.input == expected_input
4368 && signed.value == expected_value,
4369 "Finalized transaction does not match the authenticated signed payload and persisted intent"
4370 );
4371 validate_rpc_transaction_matches_payload(transaction, raw_transaction)
4372 .context("finalized transaction identity mismatch")?;
4373
4374 let trace_verification = required_verification(
4375 verification.verify_call_trace(&included.tx_hash).await,
4376 "finalized call trace",
4377 )?;
4378 validate_call_trace(
4379 &trace_verification.value,
4380 &signed,
4381 included.receipt.status,
4382 trace_purpose,
4383 deployment_manifest,
4384 )?;
4385 let deployment_verification = required_verification(
4386 verification
4387 .verify_deployment_manifest(deployment_manifest, included.block_number)
4388 .await,
4389 "inclusion deployment manifest",
4390 )?;
4391
4392 Ok(vec![
4393 verification_decision(
4394 &transaction_verification,
4395 Some(included.block_number),
4396 Some(included.block_number),
4397 ),
4398 verification_decision(
4399 &trace_verification,
4400 Some(included.block_number),
4401 Some(included.block_number),
4402 ),
4403 verification_decision(
4404 &deployment_verification,
4405 Some(included.block_number),
4406 Some(included.block_number),
4407 ),
4408 ])
4409}
4410
4411fn validate_call_trace(
4412 trace: &VerifiedCallTrace,
4413 signed: &crate::execution::transaction::DecodedSignedTransaction,
4414 receipt_success: bool,
4415 purpose: &str,
4416 manifest: &BlockchainDeploymentManifest,
4417) -> anyhow::Result<()> {
4418 anyhow::ensure!(
4419 trace.call_type == RpcCallType::Call
4420 && trace.from == signed.signer
4421 && trace.to == Some(signed.to)
4422 && trace.value == signed.value
4423 && trace.input_digest == keccak256(&signed.input)
4424 && trace.success == receipt_success,
4425 "Verified call-trace root differs from the authenticated transaction"
4426 );
4427 validate_internal_calls(&trace.calls, signed.to, purpose, manifest)
4428}
4429
4430fn validate_internal_calls(
4431 calls: &[VerifiedCallTrace],
4432 caller_context: Address,
4433 purpose: &str,
4434 manifest: &BlockchainDeploymentManifest,
4435) -> anyhow::Result<()> {
4436 for call in calls {
4437 anyhow::ensure!(
4438 call.from == caller_context,
4439 "Verified call trace child has an invalid caller context"
4440 );
4441 let target = call.to.ok_or_else(|| {
4442 anyhow::anyhow!("Verified call trace contains an operation without a target")
4443 })?;
4444 let call_type = match call.call_type {
4445 RpcCallType::Call => "call",
4446 RpcCallType::Callcode => "callcode",
4447 RpcCallType::Delegatecall => "delegatecall",
4448 RpcCallType::Staticcall => "staticcall",
4449 RpcCallType::Create | RpcCallType::Create2 | RpcCallType::Selfdestruct => {
4450 anyhow::bail!("Verified call trace contains a forbidden state-changing operation")
4451 }
4452 };
4453 let permitted = manifest.call_edges.iter().any(|edge| {
4454 edge.purpose == purpose
4455 && edge.call_type.eq_ignore_ascii_case(call_type)
4456 && Address::from_str(&edge.caller).ok() == Some(call.from)
4457 && Address::from_str(&edge.target).ok() == Some(target)
4458 });
4459 anyhow::ensure!(
4460 permitted,
4461 "Verified call trace contains an unreviewed {call_type} edge {} -> {target} for {purpose}",
4462 call.from
4463 );
4464 let child_context = match call.call_type {
4465 RpcCallType::Call | RpcCallType::Staticcall => target,
4466 RpcCallType::Callcode | RpcCallType::Delegatecall => caller_context,
4467 RpcCallType::Create | RpcCallType::Create2 | RpcCallType::Selfdestruct => {
4468 unreachable!("forbidden operations return before child traversal")
4469 }
4470 };
4471 validate_internal_calls(&call.calls, child_context, purpose, manifest)?;
4472 }
4473 Ok(())
4474}
4475
4476async fn verify_wrap_balance_increase(
4477 executor: &TransactionExecutor,
4478 weth_address: &Address,
4479 amount_wei: U256,
4480 included: &IncludedTransaction,
4481) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4482 let previous_block = included.block_number.checked_sub(1).ok_or_else(|| {
4483 anyhow::anyhow!(
4484 "Included wrap transaction {} has invalid block number 0",
4485 included.tx_hash
4486 )
4487 })?;
4488 let call = ERC20::balanceOfCall {
4489 account: executor.wallet_address,
4490 }
4491 .abi_encode();
4492 let balance_before = required_verification(
4493 executor
4494 .verification
4495 .verify_decoded_call(
4496 None,
4497 weth_address,
4498 U256::ZERO,
4499 &call,
4500 previous_block,
4501 |result| ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into),
4502 )
4503 .await,
4504 "wrapped balance before finality",
4505 )
4506 .with_context(|| {
4507 format!(
4508 "failed to verify WETH balance before included transaction {} at block {previous_block}",
4509 included.tx_hash
4510 )
4511 })?;
4512 let balance_after = required_verification(
4513 executor
4514 .verification
4515 .verify_decoded_call(
4516 None,
4517 weth_address,
4518 U256::ZERO,
4519 &call,
4520 included.block_number,
4521 |result| ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into),
4522 )
4523 .await,
4524 "wrapped balance after finality",
4525 )
4526 .with_context(|| {
4527 format!(
4528 "failed to verify WETH balance after included transaction {} at block {}",
4529 included.tx_hash, included.block_number
4530 )
4531 })?;
4532 let expected_balance = balance_before
4533 .value
4534 .checked_add(amount_wei)
4535 .ok_or_else(|| {
4536 anyhow::anyhow!(
4537 "WETH balance overflow for included transaction {} at block {}",
4538 included.tx_hash,
4539 included.block_number
4540 )
4541 })?;
4542 anyhow::ensure!(
4543 balance_after.value == expected_balance,
4544 "WETH balance after transaction {} did not increase by {amount_wei}: expected {expected_balance}, was {}",
4545 included.tx_hash,
4546 balance_after.value
4547 );
4548
4549 Ok(vec![
4550 verification_decision(&balance_before, Some(previous_block), Some(previous_block)),
4551 verification_decision(
4552 &balance_after,
4553 Some(included.block_number),
4554 Some(included.block_number),
4555 ),
4556 ])
4557}
4558
4559async fn verify_approve_allowance(
4560 executor: &TransactionExecutor,
4561 token: &Address,
4562 router: &Address,
4563 amount: U256,
4564 included: &IncludedTransaction,
4565) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4566 let call = ERC20::allowanceCall {
4567 owner: executor.wallet_address,
4568 spender: *router,
4569 }
4570 .abi_encode();
4571 let allowance = required_verification(
4572 executor
4573 .verification
4574 .verify_decoded_call(
4575 None,
4576 token,
4577 U256::ZERO,
4578 &call,
4579 included.block_number,
4580 |result| ERC20::allowanceCall::abi_decode_returns(result).map_err(Into::into),
4581 )
4582 .await,
4583 "router allowance after finality",
4584 )
4585 .with_context(|| {
4586 format!(
4587 "failed to verify router allowance after included transaction {} at block {}",
4588 included.tx_hash, included.block_number
4589 )
4590 })?;
4591 anyhow::ensure!(
4592 allowance.value == amount,
4593 "Router allowance after transaction {} does not equal the requested amount {amount}: was {}",
4594 included.tx_hash,
4595 allowance.value
4596 );
4597
4598 Ok(vec![verification_decision(
4599 &allowance,
4600 Some(included.block_number),
4601 Some(included.block_number),
4602 )])
4603}
4604
4605async fn complete_finalized_swap(
4606 plan: &SwapPlan,
4607 intent_id: i64,
4608 tx_hash: B256,
4609 fill: Option<FinalizedSwapFill>,
4610 wallet: VerifiedWalletRefresh,
4611 executor: &TransactionExecutor,
4612 emitter: &ExecutionEventEmitter,
4613) -> anyhow::Result<()> {
4614 if let Some(fill) = fill {
4615 let filled = OrderFilled::new(
4616 emitter.trader_id(),
4617 plan.order.strategy_id(),
4618 plan.order.instrument_id(),
4619 plan.order.client_order_id(),
4620 fill.venue_order_id,
4621 emitter.account_id(),
4622 fill.trade_id,
4623 plan.order.order_side(),
4624 plan.order.order_type(),
4625 fill.last_qty,
4626 fill.last_px,
4627 plan.quote_currency,
4628 LiquiditySide::Taker,
4629 execution_event_id(tx_hash, b"fill"),
4630 fill.ts_event,
4631 fill.ts_event,
4632 false,
4633 None,
4634 Some(fill.commission),
4635 None,
4636 );
4637 emitter.try_send_order_event(OrderEventAny::Filled(filled))?;
4638
4639 if fill.last_qty < plan.order.quantity() {
4640 let canceled = OrderCanceled::new(
4641 emitter.trader_id(),
4642 plan.order.strategy_id(),
4643 plan.order.instrument_id(),
4644 plan.order.client_order_id(),
4645 execution_event_id(tx_hash, b"partial_cancel"),
4646 fill.ts_event,
4647 fill.ts_event,
4648 false,
4649 Some(fill.venue_order_id),
4650 Some(emitter.account_id()),
4651 );
4652 emitter.try_send_order_event(OrderEventAny::Canceled(canceled))?;
4653 }
4654 }
4655
4656 *executor.wallet_balance.lock() = wallet.wallet_balance;
4657 emitter.try_emit_account_state(
4658 wallet.balances,
4659 vec![],
4660 true,
4661 get_atomic_clock_realtime().get_time_ns(),
4662 None,
4663 )?;
4664 executor
4665 .database
4666 .mark_execution_event_emitted(intent_id, "fill")
4667 .await
4668}
4669
4670fn send_reverted_order(
4671 emitter: &ExecutionEventEmitter,
4672 order: &OrderAny,
4673 included: &IncludedTransaction,
4674) -> anyhow::Result<()> {
4675 let ts_event = finalized_inclusion_time(included)?;
4676 let rejected = OrderRejected::new(
4677 emitter.trader_id(),
4678 order.strategy_id(),
4679 order.instrument_id(),
4680 order.client_order_id(),
4681 emitter.account_id(),
4682 format!("Transaction {} reverted on-chain", included.tx_hash).into(),
4683 execution_event_id(included.tx_hash, b"reverted"),
4684 ts_event,
4685 ts_event,
4686 false,
4687 false,
4688 );
4689 emitter.try_send_order_event(OrderEventAny::Rejected(rejected))
4690}
4691
4692fn finalized_inclusion_time(included: &IncludedTransaction) -> anyhow::Result<UnixNanos> {
4693 let inclusion = &included.finality.inclusion_header;
4694 anyhow::ensure!(
4695 inclusion.number == included.block_number
4696 && inclusion.hash == included.receipt.block_hash.to_string(),
4697 "Verified inclusion header does not match the finalized receipt"
4698 );
4699 let timestamp = inclusion.timestamp;
4700 let nanos = timestamp
4701 .checked_mul(NANOSECONDS_IN_SECOND)
4702 .ok_or_else(|| anyhow::anyhow!("Verified inclusion timestamp exceeds nanoseconds"))?;
4703 Ok(UnixNanos::from(nanos))
4704}
4705
4706fn execution_event_id(tx_hash: B256, event: &[u8]) -> UUID4 {
4707 let mut identity = Vec::with_capacity(tx_hash.len() + event.len());
4708 identity.extend_from_slice(tx_hash.as_slice());
4709 identity.extend_from_slice(event);
4710 let digest = keccak256(identity);
4711 let mut bytes = [0u8; 16];
4712 bytes.copy_from_slice(&digest[..16]);
4713 UUID4::from_bytes(bytes)
4714}
4715
4716struct FinalizedSwapFill {
4717 venue_order_id: VenueOrderId,
4718 trade_id: TradeId,
4719 last_qty: Quantity,
4720 last_px: Price,
4721 commission: Money,
4722 ts_event: UnixNanos,
4723}
4724
4725struct VerifiedWalletRefresh {
4726 wallet_balance: WalletBalance,
4727 balances: Vec<AccountBalance>,
4728 decisions: Vec<ExecutionVerificationDecision>,
4729}
4730
4731fn validate_finalized_swap_fill(
4732 plan: &SwapPlan,
4733 included: &IncludedTransaction,
4734) -> anyhow::Result<Option<FinalizedSwapFill>> {
4735 let signature =
4736 keccak256("Swap(address,address,int256,int256,uint160,uint128,int24)").to_string();
4737 let swap_logs = included
4738 .receipt
4739 .logs
4740 .iter()
4741 .filter(|log| {
4742 !log.removed
4743 && log.topics.first().is_some_and(|topic| topic == &signature)
4744 && Address::from_str(&log.address).ok() == Some(plan.pool_address)
4745 })
4746 .collect::<Vec<_>>();
4747 anyhow::ensure!(
4748 swap_logs.len() == 1,
4749 "Finalized transaction {} emitted {} Swap logs from expected pool {}; expected exactly one",
4750 included.tx_hash,
4751 swap_logs.len(),
4752 plan.pool_address
4753 );
4754 let log = swap_logs[0];
4755 let log_transaction_hash = B256::from_str(&rpc_helpers::extract_transaction_hash(log)?)
4756 .with_context(|| "Invalid finalized Swap log transaction hash")?;
4757 let log_block_hash = log
4758 .block_hash
4759 .as_deref()
4760 .ok_or_else(|| anyhow::anyhow!("Finalized Swap log has no block hash"))?;
4761 anyhow::ensure!(
4762 log_transaction_hash == included.tx_hash
4763 && rpc_helpers::extract_block_number(log)? == included.block_number
4764 && u64::from(rpc_helpers::extract_transaction_index(log)?)
4765 == included.receipt.transaction_index
4766 && B256::from_str(log_block_hash)
4767 .with_context(|| "Invalid finalized Swap log block hash")?
4768 == included.receipt.block_hash,
4769 "Finalized Swap log position does not match transaction {}",
4770 included.tx_hash
4771 );
4772
4773 let dex = crate::exchanges::get_dex_extended(plan.pool.chain.name, &plan.pool.dex.name)
4774 .ok_or_else(|| {
4775 anyhow::anyhow!(
4776 "No RPC Swap decoder for {}:{}",
4777 plan.pool.chain.name,
4778 plan.pool.dex.name
4779 )
4780 })?;
4781 let event = dex.parse_swap_event_rpc(log)?;
4782 let (base_amount, quote_amount) =
4783 if plan.pool.get_base_token().address == plan.pool.token0.address {
4784 (event.amount0, event.amount1)
4785 } else {
4786 (event.amount1, event.amount0)
4787 };
4788 let last_qty = match plan.order.order_side() {
4789 OrderSide::Sell => {
4790 anyhow::ensure!(
4791 base_amount.is_positive() && base_amount.unsigned_abs() == plan.amount_in,
4792 "Finalized Swap input {base_amount} does not match the persisted amount {}",
4793 plan.amount_in
4794 );
4795 plan.order.quantity()
4796 }
4797 OrderSide::Buy => {
4798 anyhow::ensure!(
4799 quote_amount.is_positive() && quote_amount.unsigned_abs() == plan.amount_in,
4800 "Finalized Swap input {quote_amount} does not match the persisted amount {}",
4801 plan.amount_in
4802 );
4803 anyhow::ensure!(
4804 base_amount.is_negative(),
4805 "Finalized Swap base amount {base_amount} is not a BUY output"
4806 );
4807 raw_amount_to_quantity(
4808 base_amount.unsigned_abs(),
4809 plan.pool.get_base_token().decimals,
4810 )?
4811 }
4812 };
4813
4814 let block = &included.finality.inclusion_header;
4815 anyhow::ensure!(
4816 block.number == included.block_number
4817 && block.hash == included.receipt.block_hash.to_string(),
4818 "Verified inclusion header {} does not match receipt hash {}",
4819 included.block_number,
4820 included.receipt.block_hash
4821 );
4822 let timestamp_ns = block
4823 .timestamp
4824 .checked_mul(NANOSECONDS_IN_SECOND)
4825 .ok_or_else(|| anyhow::anyhow!("Finalized block timestamp overflows nanoseconds"))?;
4826 let mut swap = event.to_pool_swap(
4827 plan.pool.chain.clone(),
4828 plan.instrument_id,
4829 plan.pool.pool_identifier,
4830 UnixNanos::from(timestamp_ns),
4831 );
4832 swap.calculate_trade_info(&plan.pool.token0, &plan.pool.token1, None)?;
4833 let trade = swap
4834 .trade_info
4835 .as_ref()
4836 .ok_or_else(|| anyhow::anyhow!("Finalized Swap has no calculated trade information"))?;
4837 anyhow::ensure!(
4838 trade.order_side == plan.order.order_side(),
4839 "Finalized Swap side {} does not match {} order",
4840 trade.order_side,
4841 plan.order.order_side()
4842 );
4843 let gas_cost = included
4844 .receipt
4845 .effective_gas_price
4846 .checked_mul(U256::from(included.receipt.gas_used))
4847 .ok_or_else(|| anyhow::anyhow!("Finalized transaction gas commission overflow"))?;
4848 let commission = Money::from_u256(gas_cost, plan.pool.chain.native_currency())?;
4849 let trade_digest = keccak256(format!("{}:{}", included.tx_hash, swap.log_index));
4850 let trade_digest = trade_digest.to_string();
4851 let trade_id = TradeId::new_checked(&trade_digest[2..38])?;
4852
4853 if plan
4854 .order
4855 .trade_ids()
4856 .iter()
4857 .any(|existing| **existing == trade_id)
4858 {
4859 return Ok(None);
4860 }
4861
4862 let venue_order_id = VenueOrderId::new_checked(included.tx_hash.to_string())?;
4863 let ts_event = UnixNanos::from(timestamp_ns);
4864 let last_px = match plan.order.order_side() {
4865 OrderSide::Buy => fill_price_from_quote(last_qty, plan.amount_in, plan.quote_currency)?,
4866 _ => trade.execution_price,
4867 };
4868 Ok(Some(FinalizedSwapFill {
4869 venue_order_id,
4870 trade_id,
4871 last_qty,
4872 last_px,
4873 commission,
4874 ts_event,
4875 }))
4876}
4877
4878async fn load_verified_wallet_after_fill(
4879 plan: &SwapPlan,
4880 included: &IncludedTransaction,
4881 executor: &TransactionExecutor,
4882) -> anyhow::Result<VerifiedWalletRefresh> {
4883 let mut token_universe = executor.wallet_balance.lock().token_universe.clone();
4884 token_universe.insert(plan.pool.token0.address);
4885 token_universe.insert(plan.pool.token1.address);
4886
4887 let native_amount = required_verification(
4888 executor
4889 .verification
4890 .verify_balance(&executor.wallet_address, included.block_number)
4891 .await,
4892 "finalized native balance",
4893 )?;
4894 let native_balance = Money::from_u256(native_amount.value, plan.pool.chain.native_currency())?;
4895 let mut decisions = vec![verification_decision(
4896 &native_amount,
4897 Some(included.block_number),
4898 Some(included.block_number),
4899 )];
4900 let mut token_addresses = token_universe.iter().copied().collect::<Vec<_>>();
4901 token_addresses.sort_unstable();
4902 let mut token_balances = Vec::with_capacity(token_addresses.len());
4903 for address in token_addresses {
4904 let identities = executor
4905 .deployment_manifest
4906 .tokens
4907 .iter()
4908 .filter(|identity| Address::from_str(&identity.address).ok() == Some(address))
4909 .collect::<Vec<_>>();
4910 anyhow::ensure!(
4911 identities.len() == 1,
4912 "Wallet token {address} does not have exactly one deployment manifest identity"
4913 );
4914 let identity = identities[0];
4915 let token = Token::new(
4916 plan.pool.chain.clone(),
4917 address,
4918 identity.name.clone(),
4919 identity.symbol.clone(),
4920 identity.decimals,
4921 );
4922 let call = ERC20::balanceOfCall {
4923 account: executor.wallet_address,
4924 }
4925 .abi_encode();
4926 let amount = required_verification(
4927 executor
4928 .verification
4929 .verify_decoded_call(
4930 None,
4931 &address,
4932 U256::ZERO,
4933 &call,
4934 included.block_number,
4935 |result| ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into),
4936 )
4937 .await,
4938 "finalized token balance",
4939 )?;
4940 decisions.push(verification_decision(
4941 &amount,
4942 Some(included.block_number),
4943 Some(included.block_number),
4944 ));
4945 token_balances.push(TokenBalance::new(amount.value, token));
4946 }
4947
4948 let mut wallet_balance = WalletBalance::new(token_universe);
4949 let balances = wallet_balance.replace_balances(native_balance, token_balances)?;
4950 Ok(VerifiedWalletRefresh {
4951 wallet_balance,
4952 balances,
4953 decisions,
4954 })
4955}
4956
4957async fn verify_connect_capabilities(
4958 verification: &VerificationCoordinator,
4959 manifest: &BlockchainDeploymentManifest,
4960 wallet: Address,
4961 weth: Address,
4962 block: u64,
4963) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4964 let contract = manifest
4965 .contracts
4966 .first()
4967 .ok_or_else(|| anyhow::anyhow!("Deployment manifest has no capability probe target"))?;
4968 let contract_address = Address::from_str(&contract.address)
4969 .with_context(|| "Deployment manifest capability probe target is invalid")?;
4970 let storage = required_verification(
4971 verification
4972 .verify_storage(&contract_address, &B256::ZERO, block)
4973 .await,
4974 "Blockchain explicit-height storage capability",
4975 )?;
4976
4977 let balance_call = ERC20::balanceOfCall { account: wallet }.abi_encode();
4978 let gas = required_verification(
4979 verification
4980 .verify_gas_estimate(&wallet, &weth, U256::ZERO, &balance_call, block)
4981 .await,
4982 "Blockchain explicit-height gas capability",
4983 )?;
4984
4985 let mut decisions = vec![
4986 verification_decision(&storage, Some(block), Some(block)),
4987 verification_decision(&gas, Some(block), Some(block)),
4988 ];
4989
4990 for pool in &manifest.pools {
4991 let quote_contract = Address::from_str(&pool.quote_contract)
4992 .with_context(|| "Deployment manifest quote capability target is invalid")?;
4993 let token_in = Address::from_str(&pool.token0)
4994 .with_context(|| "Deployment manifest quote input token is invalid")?;
4995 let token_out = Address::from_str(&pool.token1)
4996 .with_context(|| "Deployment manifest quote output token is invalid")?;
4997 let fee = U24::try_from(pool.fee)
4998 .map_err(|_| anyhow::anyhow!("Deployment manifest pool fee is invalid"))?;
4999 let quote = required_verification(
5000 verification
5001 .verify_quote_exact_input_single(
5002 "e_contract,
5003 token_in,
5004 token_out,
5005 U256::from(1u64),
5006 fee,
5007 block,
5008 )
5009 .await,
5010 "Blockchain explicit-height quote capability",
5011 )?;
5012 decisions.push(verification_decision("e, Some(block), Some(block)));
5013 }
5014
5015 let trace = required_verification(
5016 verification.verify_call_trace_capability().await,
5017 "Blockchain call trace capability",
5018 )?;
5019 decisions.push(verification_decision(&trace, None, None));
5020 Ok(decisions)
5021}
5022
5023async fn check_swap_preconditions(
5028 plan: &SwapPlan,
5029 block: u64,
5030 executor: &TransactionExecutor,
5031) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
5032 let mut decisions = Vec::new();
5033 let factory_call = UniswapV3RouterState::factoryCall.abi_encode();
5034 let router_factory = required_verification(
5035 executor
5036 .verification
5037 .verify_decoded_call(
5038 None,
5039 &plan.router,
5040 U256::ZERO,
5041 &factory_call,
5042 block,
5043 |result| {
5044 UniswapV3RouterState::factoryCall::abi_decode_returns(result)
5045 .map_err(Into::into)
5046 },
5047 )
5048 .await,
5049 "swap router factory",
5050 )?;
5051 anyhow::ensure!(
5052 router_factory.value == plan.factory,
5053 "Router {} reports an unexpected factory",
5054 plan.router,
5055 );
5056 decisions.push(verification_decision(
5057 &router_factory,
5058 Some(block),
5059 Some(block),
5060 ));
5061
5062 let weth_call = UniswapV3RouterState::WETH9Call.abi_encode();
5063 let router_weth = required_verification(
5064 executor
5065 .verification
5066 .verify_decoded_call(
5067 None,
5068 &plan.router,
5069 U256::ZERO,
5070 &weth_call,
5071 block,
5072 |result| {
5073 UniswapV3RouterState::WETH9Call::abi_decode_returns(result).map_err(Into::into)
5074 },
5075 )
5076 .await,
5077 "swap router wrapped native",
5078 )?;
5079 anyhow::ensure!(
5080 router_weth.value == plan.weth,
5081 "Router {} reports an unexpected wrapped native contract",
5082 plan.router,
5083 );
5084 decisions.push(verification_decision(
5085 &router_weth,
5086 Some(block),
5087 Some(block),
5088 ));
5089
5090 let pool_call = UniswapV3Factory::getPoolCall {
5091 tokenA: plan.token_in,
5092 tokenB: plan.token_out,
5093 fee: plan.fee,
5094 }
5095 .abi_encode();
5096 let registered_pool = required_verification(
5097 executor
5098 .verification
5099 .verify_decoded_call(
5100 None,
5101 &plan.factory,
5102 U256::ZERO,
5103 &pool_call,
5104 block,
5105 |result| {
5106 UniswapV3Factory::getPoolCall::abi_decode_returns(result).map_err(Into::into)
5107 },
5108 )
5109 .await,
5110 "swap factory pool",
5111 )?;
5112 anyhow::ensure!(
5113 registered_pool.value == plan.pool_address,
5114 "Factory resolves an unexpected pool for the swap token pair and fee"
5115 );
5116 decisions.push(verification_decision(
5117 ®istered_pool,
5118 Some(block),
5119 Some(block),
5120 ));
5121
5122 for token in [&plan.pool.token0, &plan.pool.token1] {
5123 let decimals_call = ERC20::decimalsCall.abi_encode();
5124 let decimals = required_verification(
5125 executor
5126 .verification
5127 .verify_decoded_call(
5128 None,
5129 &token.address,
5130 U256::ZERO,
5131 &decimals_call,
5132 block,
5133 |result| ERC20::decimalsCall::abi_decode_returns(result).map_err(Into::into),
5134 )
5135 .await,
5136 "swap token decimals",
5137 )?;
5138 anyhow::ensure!(
5139 decimals.value == token.decimals,
5140 "Token {} reports unexpected decimals",
5141 token.address,
5142 );
5143 decisions.push(verification_decision(&decimals, Some(block), Some(block)));
5144 }
5145
5146 let allowance_call = ERC20::allowanceCall {
5147 owner: executor.wallet_address,
5148 spender: plan.router,
5149 }
5150 .abi_encode();
5151 let allowance = required_verification(
5152 executor
5153 .verification
5154 .verify_decoded_call(
5155 None,
5156 &plan.token_in,
5157 U256::ZERO,
5158 &allowance_call,
5159 block,
5160 |result| ERC20::allowanceCall::abi_decode_returns(result).map_err(Into::into),
5161 )
5162 .await,
5163 "swap input allowance",
5164 )?;
5165
5166 if allowance.value < plan.amount_in {
5167 anyhow::bail!(
5168 "Router allowance {} is below the swap amount {} for input token {}; approve the router explicitly before submitting",
5169 allowance.value,
5170 plan.amount_in,
5171 plan.token_in
5172 );
5173 }
5174 decisions.push(verification_decision(&allowance, Some(block), Some(block)));
5175
5176 let balance_call = ERC20::balanceOfCall {
5177 account: executor.wallet_address,
5178 }
5179 .abi_encode();
5180 let balance = required_verification(
5181 executor
5182 .verification
5183 .verify_decoded_call(
5184 None,
5185 &plan.token_in,
5186 U256::ZERO,
5187 &balance_call,
5188 block,
5189 |result| ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into),
5190 )
5191 .await,
5192 "swap input balance",
5193 )?;
5194
5195 if balance.value < plan.amount_in {
5196 anyhow::bail!(
5197 "Input token {} balance {} is below the swap amount {}",
5198 plan.token_in,
5199 balance.value,
5200 plan.amount_in
5201 );
5202 }
5203 decisions.push(verification_decision(&balance, Some(block), Some(block)));
5204
5205 Ok(decisions)
5206}
5207
5208fn quantity_to_raw_amount(quantity: Quantity, decimals: u8) -> anyhow::Result<U256> {
5215 if quantity.is_zero() {
5216 anyhow::bail!("Order quantity must be positive");
5217 }
5218
5219 let raw = U256::from(quantity.raw);
5220 let raw_precision = quantity.precision.max(FIXED_PRECISION);
5221 if decimals >= raw_precision {
5222 let scale = U256::from(10u64)
5223 .checked_pow(U256::from(decimals - raw_precision))
5224 .ok_or_else(|| anyhow::anyhow!("Order amount scaling overflow"))?;
5225 raw.checked_mul(scale).ok_or_else(|| {
5226 anyhow::anyhow!("Order amount overflow scaling quantity to raw token units")
5227 })
5228 } else {
5229 let divisor = U256::from(10u64)
5230 .checked_pow(U256::from(raw_precision - decimals))
5231 .ok_or_else(|| anyhow::anyhow!("Order amount scaling overflow"))?;
5232 if !(raw % divisor).is_zero() {
5233 anyhow::bail!(
5234 "Order quantity {quantity} is not exactly representable in {decimals} base token decimals"
5235 );
5236 }
5237 Ok(raw / divisor)
5238 }
5239}
5240
5241fn swap_token_pair(
5242 side: OrderSide,
5243 base: Address,
5244 quote: Address,
5245) -> anyhow::Result<(Address, Address)> {
5246 match side {
5247 OrderSide::Sell => Ok((base, quote)),
5248 OrderSide::Buy => Ok((quote, base)),
5249 }
5250}
5251
5252fn fill_price_from_quote(
5253 last_qty: Quantity,
5254 quote_amount: U256,
5255 quote_currency: Currency,
5256) -> anyhow::Result<Price> {
5257 let quote = Money::from_u256(quote_amount, quote_currency)?;
5258 Price::from_decimal_dp(quote.as_decimal() / last_qty.as_decimal(), FIXED_PRECISION)
5259 .map_err(anyhow::Error::from)
5260}
5261
5262fn raw_amount_to_quantity(amount: U256, decimals: u8) -> anyhow::Result<Quantity> {
5263 if amount.is_zero() {
5264 anyhow::bail!("Executed amount must be positive");
5265 }
5266 let quantity = if decimals >= FIXED_PRECISION {
5267 let scale = U256::from(10u64)
5268 .checked_pow(U256::from(decimals - FIXED_PRECISION))
5269 .ok_or_else(|| anyhow::anyhow!("Executed amount scaling overflow"))?;
5270 Quantity::from_u256(amount / scale, FIXED_PRECISION).map_err(anyhow::Error::from)?
5271 } else {
5272 Quantity::from_u256(amount, decimals).map_err(anyhow::Error::from)?
5273 };
5274
5275 if quantity.is_zero() {
5276 anyhow::bail!(
5277 "Executed amount {amount} is below representable quantity precision {FIXED_PRECISION}"
5278 );
5279 }
5280 Ok(quantity)
5281}
5282
5283fn exact_output_amount(quote: &SwapQuote, zero_for_one: bool) -> anyhow::Result<U256> {
5285 let amount = if zero_for_one {
5286 quote.amount1
5287 } else {
5288 quote.amount0
5289 };
5290
5291 if !amount.is_negative() {
5292 anyhow::bail!("Swap quote output amount {amount} is not a positive output");
5293 }
5294 Ok(amount.unsigned_abs())
5295}
5296
5297fn derive_min_amount_out(quoted_amount_out: U256, slippage_bps: u32) -> anyhow::Result<U256> {
5301 if slippage_bps >= BPS_DENOMINATOR {
5302 anyhow::bail!("Slippage {slippage_bps} bps must be below {BPS_DENOMINATOR}");
5303 }
5304 let min_amount_out = quoted_amount_out
5305 .checked_mul(U256::from(BPS_DENOMINATOR - slippage_bps))
5306 .and_then(|scaled| scaled.checked_div(U256::from(BPS_DENOMINATOR)))
5307 .ok_or_else(|| anyhow::anyhow!("Minimum output derivation overflow"))?;
5308 if min_amount_out.is_zero() {
5309 anyhow::bail!(
5310 "Derived minimum output is zero for quoted output {quoted_amount_out} at {slippage_bps} bps slippage"
5311 );
5312 }
5313 Ok(min_amount_out)
5314}
5315
5316impl BlockchainExecutionClient {
5317 async fn build_execution_verification_migration(
5318 &self,
5319 snapshot: ExecutionVerificationMigrationSnapshot,
5320 finalized: VerifiedBlockHeader,
5321 finalized_headers: &[VerifiedBlockHeader],
5322 nonce_verification: &Verified<u64>,
5323 ) -> anyhow::Result<ExecutionVerificationMigration> {
5324 let next_canonical_nonce = nonce_verification.value;
5325 let mut hashes_by_intent: HashMap<i64, Vec<&ExecutionTransactionHashRow>> = HashMap::new();
5326 for hash in &snapshot.hashes {
5327 hashes_by_intent
5328 .entry(hash.intent_id)
5329 .or_default()
5330 .push(hash);
5331 }
5332 let active_count = snapshot
5333 .intents
5334 .iter()
5335 .filter(|intent| intent.active)
5336 .count();
5337 anyhow::ensure!(
5338 active_count <= 1,
5339 "Retained execution history has multiple active signer owners"
5340 );
5341
5342 let mut nonce_owners = HashMap::new();
5343 let mut records = Vec::with_capacity(snapshot.intents.len());
5344 let finalized_headers = finalized_headers
5345 .iter()
5346 .map(durable_verified_header)
5347 .collect::<Vec<_>>();
5348
5349 for intent in &snapshot.intents {
5350 anyhow::ensure!(
5351 intent.chain_id == self.chain.chain_id
5352 && intent.wallet_address == self.config.wallet_address,
5353 "Retained execution intent belongs to another signer"
5354 );
5355 let purpose = TransactionPurpose::parse(&intent.purpose).ok_or_else(|| {
5356 anyhow::anyhow!("Retained execution intent has an unsupported purpose")
5357 })?;
5358 let hashes = hashes_by_intent
5359 .get(&intent.id)
5360 .map(Vec::as_slice)
5361 .unwrap_or_default();
5362 let current = hashes
5363 .iter()
5364 .copied()
5365 .filter(|hash| hash.current)
5366 .collect::<Vec<_>>();
5367 anyhow::ensure!(
5368 current.len() <= 1,
5369 "Retained execution intent {} has multiple current hashes",
5370 intent.id
5371 );
5372 let current = current.first().copied();
5373 let mut authenticated = HashMap::new();
5374
5375 for hash in hashes {
5376 if hash.payload_expected {
5377 let raw = open_execution_payload(
5378 self.payload_keys
5379 .as_deref()
5380 .expect("Postgres execution requires payload keys"),
5381 self.payload_policy(),
5382 intent,
5383 hash,
5384 "verification migration",
5385 )?;
5386 authenticated.insert(hash.id, raw);
5387 } else {
5388 anyhow::ensure!(
5389 hash.raw_transaction.is_none() && hash.sealed_transaction.is_none(),
5390 "Unowned replacement hash retains signed bytes"
5391 );
5392 }
5393 }
5394
5395 if let Some(nonce) = intent.nonce {
5396 anyhow::ensure!(
5397 nonce_owners.insert(nonce, intent.id).is_none(),
5398 "Retained execution history has duplicate signer nonce ownership"
5399 );
5400 }
5401
5402 let base_decision = verification_decision(
5403 nonce_verification,
5404 Some(finalized.number),
5405 Some(finalized.number),
5406 );
5407
5408 if !intent.active {
5409 if matches!(intent.status.as_str(), "finalized" | "reverted") {
5410 let expected_marker =
5411 if purpose == TransactionPurpose::Swap && intent.status == "finalized" {
5412 intent.fill_emitted
5413 } else {
5414 intent.terminal_emitted
5415 };
5416 anyhow::ensure!(
5417 expected_marker,
5418 "Released terminal intent {} has no durable event marker",
5419 intent.id
5420 );
5421 } else {
5422 anyhow::ensure!(
5423 matches!(intent.status.as_str(), "dropped" | "recoverable")
5424 && authenticated.is_empty(),
5425 "Released nonterminal intent {} retains signed ownership",
5426 intent.id
5427 );
5428 records.push(ExecutionVerificationMigrationRecord {
5429 intent_id: intent.id,
5430 nonce: intent.nonce,
5431 transaction_hash: None,
5432 terminal_status: None,
5433 block_number: None,
5434 block_hash: None,
5435 receipt_success: None,
5436 gas_used: None,
5437 effective_gas_price: None,
5438 recover_prepared: false,
5439 decisions: vec![base_decision],
5440 });
5441 continue;
5442 }
5443 }
5444
5445 if intent.active && intent.nonce.is_none() {
5446 anyhow::ensure!(
5447 intent.status == "prepared" && hashes.is_empty(),
5448 "Unassigned active intent {} is not an unsigned preparation",
5449 intent.id
5450 );
5451 records.push(ExecutionVerificationMigrationRecord {
5452 intent_id: intent.id,
5453 nonce: None,
5454 transaction_hash: None,
5455 terminal_status: None,
5456 block_number: None,
5457 block_hash: None,
5458 receipt_success: None,
5459 gas_used: None,
5460 effective_gas_price: None,
5461 recover_prepared: true,
5462 decisions: vec![base_decision],
5463 });
5464 continue;
5465 }
5466
5467 let nonce = intent.nonce.ok_or_else(|| {
5468 anyhow::anyhow!("Retained signed intent {} has no nonce", intent.id)
5469 })?;
5470 let current = current.ok_or_else(|| {
5471 anyhow::anyhow!("Retained signed intent {} has no current hash", intent.id)
5472 })?;
5473 let raw_transaction = authenticated.get(¤t.id).ok_or_else(|| {
5474 anyhow::anyhow!(
5475 "Retained signed intent {} has no authenticated current payload",
5476 intent.id
5477 )
5478 })?;
5479
5480 if intent.active && nonce == next_canonical_nonce {
5481 anyhow::ensure!(
5482 !matches!(intent.status.as_str(), "finalized" | "reverted"),
5483 "Active terminal intent conflicts with the canonical nonce ledger"
5484 );
5485 records.push(ExecutionVerificationMigrationRecord {
5486 intent_id: intent.id,
5487 nonce: Some(nonce),
5488 transaction_hash: Some(current.transaction_hash.clone()),
5489 terminal_status: None,
5490 block_number: None,
5491 block_hash: None,
5492 receipt_success: None,
5493 gas_used: None,
5494 effective_gas_price: None,
5495 recover_prepared: false,
5496 decisions: vec![base_decision],
5497 });
5498 continue;
5499 }
5500 anyhow::ensure!(
5501 nonce < next_canonical_nonce,
5502 "Retained active nonce {nonce} is above canonical nonce {next_canonical_nonce}"
5503 );
5504
5505 let receipt_verification = required_verification(
5506 self.verification
5507 .verify_receipt(&B256::from_str(¤t.transaction_hash).with_context(
5508 || {
5509 format!(
5510 "Retained transaction hash {} is invalid",
5511 current.transaction_hash
5512 )
5513 },
5514 )?)
5515 .await,
5516 "migration receipt",
5517 )?;
5518 let receipt = receipt_verification.value.clone();
5519 anyhow::ensure!(
5520 receipt.block_number <= finalized.number,
5521 "Retained terminal receipt is above the verified finalized boundary"
5522 );
5523 let inclusion_verification = required_verification(
5524 self.verification.verify_block(receipt.block_number).await,
5525 "migration inclusion header",
5526 )?;
5527 anyhow::ensure!(
5528 inclusion_verification.value.hash == receipt.block_hash
5529 && finalized_headers.iter().any(|header| {
5530 header.number == receipt.block_number
5531 && header.hash == receipt.block_hash.to_string()
5532 }),
5533 "Retained terminal receipt is not on the verified finalized ancestry"
5534 );
5535 let tx_hash = B256::from_str(¤t.transaction_hash)
5536 .context("Retained transaction hash is invalid")?;
5537 let included = IncludedTransaction {
5538 intent_id: intent.id,
5539 nonce,
5540 tx_hash,
5541 block_number: receipt.block_number,
5542 receipt: receipt.clone(),
5543 finality: StableFinality {
5544 decisions: Vec::new(),
5545 inclusion_header: durable_verified_header(&inclusion_verification.value),
5546 finalized_headers: finalized_headers.clone(),
5547 },
5548 };
5549 let trace_purpose = match purpose {
5550 TransactionPurpose::Wrap => "wrap",
5551 TransactionPurpose::Approve => "approve",
5552 TransactionPurpose::Swap => {
5553 match self.restore_swap_plan(intent)?.order.order_side() {
5554 OrderSide::Sell => "swap_sell",
5555 OrderSide::Buy => "swap_buy",
5556 }
5557 }
5558 };
5559 let mut decisions = vec![
5560 verification_decision(
5561 &receipt_verification,
5562 Some(receipt.block_number),
5563 Some(receipt.block_number),
5564 ),
5565 verification_decision(
5566 &inclusion_verification,
5567 Some(receipt.block_number),
5568 Some(receipt.block_number),
5569 ),
5570 ];
5571 decisions.extend(
5572 verify_finalized_transaction_identity(
5573 &included,
5574 intent,
5575 nonce,
5576 raw_transaction,
5577 &self.verification,
5578 self.wallet_address,
5579 self.chain.chain_id,
5580 &self
5581 .config
5582 .verification
5583 .as_ref()
5584 .expect("verification config validated")
5585 .deployment_manifest,
5586 trace_purpose,
5587 )
5588 .await?,
5589 );
5590 let terminal_status = if receipt.status {
5591 TransactionStatus::Finalized
5592 } else {
5593 TransactionStatus::Reverted
5594 };
5595
5596 if !intent.active {
5597 anyhow::ensure!(
5598 intent.status == terminal_status.as_str(),
5599 "Released terminal intent status conflicts with its verified receipt"
5600 );
5601 }
5602 records.push(ExecutionVerificationMigrationRecord {
5603 intent_id: intent.id,
5604 nonce: Some(nonce),
5605 transaction_hash: Some(current.transaction_hash.clone()),
5606 terminal_status: Some(terminal_status),
5607 block_number: Some(receipt.block_number),
5608 block_hash: Some(receipt.block_hash.to_string()),
5609 receipt_success: Some(receipt.status),
5610 gas_used: Some(receipt.gas_used),
5611 effective_gas_price: Some(receipt.effective_gas_price.to_string()),
5612 recover_prepared: false,
5613 decisions,
5614 });
5615 }
5616
5617 Ok(ExecutionVerificationMigration { snapshot, records })
5618 }
5619}
5620
5621#[async_trait(?Send)]
5622impl ExecutionClient for BlockchainExecutionClient {
5623 fn is_connected(&self) -> bool {
5624 self.core.is_connected()
5625 }
5626
5627 fn client_id(&self) -> ClientId {
5628 self.core.client_id
5629 }
5630
5631 fn account_id(&self) -> AccountId {
5632 self.core.account_id
5633 }
5634
5635 fn venue(&self) -> Venue {
5636 self.core.venue
5637 }
5638
5639 fn handles_order_venue(&self, venue: Venue) -> bool {
5640 venue.parse_dex().is_ok_and(|(blockchain, dex_type)| {
5641 blockchain == self.chain.name && dex_type == DexType::UniswapV3
5642 })
5643 }
5644
5645 fn oms_type(&self) -> OmsType {
5646 self.core.oms_type
5647 }
5648
5649 fn get_account(&self) -> Option<AccountAny> {
5650 self.core.cache().account_owned(&self.core.account_id)
5651 }
5652
5653 fn generate_account_state(
5654 &self,
5655 balances: Vec<AccountBalance>,
5656 margins: Vec<MarginBalance>,
5657 reported: bool,
5658 ts_event: UnixNanos,
5659 info: Option<Params>,
5660 ) -> anyhow::Result<()> {
5661 self.emitter
5662 .try_emit_account_state(balances, margins, reported, ts_event, info)
5663 }
5664
5665 fn start(&mut self) -> anyhow::Result<()> {
5666 if self.core.is_started() {
5667 return Ok(());
5668 }
5669
5670 self.emitter.set_sender(get_exec_event_sender());
5671 self.core.set_started();
5672 log::info!(
5673 "Started: client_id={}, account_id={}",
5674 self.core.client_id,
5675 self.core.account_id
5676 );
5677 Ok(())
5678 }
5679
5680 fn stop(&mut self) -> anyhow::Result<()> {
5681 if self.core.is_stopped() {
5682 return Ok(());
5683 }
5684
5685 self.pending_tasks.begin_shutdown();
5686 self.signer = None;
5687 self.core.set_stopped();
5688 self.core.set_disconnected();
5689 log::info!("Stopped: client_id={}", self.core.client_id);
5690 Ok(())
5691 }
5692
5693 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
5694 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
5695
5696 if order.is_closed() {
5697 log::warn!("Cannot submit closed order {}", order.client_order_id());
5698 return Ok(());
5699 }
5700
5701 if !self.pending_tasks.is_open() {
5702 self.emitter
5703 .emit_order_denied(&order, "Blockchain execution client is shutting down");
5704 return Ok(());
5705 }
5706
5707 let plan = match self.prepare_swap(&cmd, &order) {
5708 Ok(plan) => plan,
5709 Err(e) => {
5710 self.emitter.emit_order_denied(&order, &e.to_string());
5711 return Ok(());
5712 }
5713 };
5714
5715 let executor = match self.transaction_executor() {
5716 Ok(executor) => executor,
5717 Err(e) => {
5718 self.emitter.emit_order_denied(&order, &e.to_string());
5719 return Ok(());
5720 }
5721 };
5722
5723 let emitter = self.emitter.clone();
5724 let max_quote_age_blocks = self.transaction_limits.max_quote_age_blocks;
5725 let deadline_seconds = self.transaction_limits.deadline_seconds;
5726 let client_order_id = order.client_order_id();
5727
5728 let future = async move {
5729 if let Err(e) = execute_swap(
5730 plan,
5731 executor,
5732 emitter,
5733 max_quote_age_blocks,
5734 deadline_seconds,
5735 )
5736 .await
5737 {
5738 log::warn!("Swap execution for order {client_order_id} failed: {e:?}");
5739 }
5740 };
5741
5742 if let Err(e) = self.pending_tasks.spawn(future) {
5743 release_preparing_slot(&self.in_flight);
5744 log::warn!("Skipping blockchain swap after shutdown began: {e}");
5745 }
5746
5747 Ok(())
5748 }
5749
5750 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
5751 let orders = self
5752 .core
5753 .cache()
5754 .orders_for_ids(&cmd.order_list.client_order_ids, &cmd);
5755
5756 for order in &orders {
5757 if order.is_closed() {
5758 log::warn!("Cannot submit closed order {}", order.client_order_id());
5759 continue;
5760 }
5761
5762 self.emitter
5763 .emit_order_denied(order, ORDER_LIST_UNSUPPORTED);
5764 }
5765
5766 Ok(())
5767 }
5768
5769 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
5770 let Ok(order) = self.core.cache().try_order_owned(&cmd.client_order_id) else {
5771 log::warn!("Cannot modify unknown order {}", cmd.client_order_id);
5772 return Ok(());
5773 };
5774
5775 self.emitter.emit_order_modify_rejected(
5776 &order,
5777 cmd.venue_order_id,
5778 ORDER_MODIFY_UNSUPPORTED,
5779 get_atomic_clock_realtime().get_time_ns(),
5780 );
5781 Ok(())
5782 }
5783
5784 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
5785 let Ok(order) = self.core.cache().try_order_owned(&cmd.client_order_id) else {
5786 log::warn!("Cannot cancel unknown order {}", cmd.client_order_id);
5787 return Ok(());
5788 };
5789
5790 self.emitter.emit_order_cancel_rejected(
5791 &order,
5792 cmd.venue_order_id,
5793 ORDER_CANCEL_UNSUPPORTED,
5794 get_atomic_clock_realtime().get_time_ns(),
5795 );
5796 Ok(())
5797 }
5798
5799 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
5800 log::warn!(
5801 "Cancel-all for {} is not supported on the blockchain execution client",
5802 cmd.instrument_id
5803 );
5804 Ok(())
5805 }
5806
5807 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
5808 for cancel in cmd.cancels {
5809 self.cancel_order(cancel)?;
5810 }
5811 Ok(())
5812 }
5813
5814 fn query_account(&self, cmd: QueryAccount) -> anyhow::Result<()> {
5815 anyhow::ensure!(
5816 cmd.account_id == self.core.account_id,
5817 "Query account ID {} does not match client account ID {}",
5818 cmd.account_id,
5819 self.core.account_id
5820 );
5821 anyhow::ensure!(self.core.is_started(), "Execution client is not started");
5822
5823 let balances = self.wallet_balance.lock().as_account_balances()?;
5824 self.generate_account_state(
5825 balances,
5826 vec![],
5827 true,
5828 get_atomic_clock_realtime().get_time_ns(),
5829 None,
5830 )
5831 }
5832
5833 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
5834 log::warn!(
5835 "Order queries are not supported on the blockchain execution client; cannot query {}",
5836 cmd.client_order_id
5837 );
5838 Ok(())
5839 }
5840
5841 async fn connect(&mut self) -> anyhow::Result<()> {
5842 if self.core.is_connected() {
5843 log::warn!("Blockchain execution client already connected");
5844 return Ok(());
5845 }
5846
5847 log::info!(
5848 "Connecting to blockchain execution client on chain {}",
5849 self.chain.name
5850 );
5851
5852 if !self.pending_tasks.is_open() || !self.pending_tasks.is_empty() {
5853 self.pending_tasks.begin_shutdown();
5854 self.pending_tasks
5855 .finish_shutdown(Duration::from_secs(5), Duration::from_secs(2))
5856 .await
5857 .map_err(|e| anyhow::anyhow!("Failed to terminate blockchain submissions: {e}"))?;
5858 self.signer = None;
5859 self.pending_tasks
5860 .start_generation()
5861 .map_err(|e| anyhow::anyhow!("Failed to start blockchain task generation: {e}"))?;
5862 }
5863 release_preparing_slot(&self.in_flight);
5864
5865 let setup_guard = TaskGroupGuard::new(&[&self.pending_tasks], || {});
5866
5867 let payload_keys = PayloadKeySet::load(
5868 self.config.payload_key_env.as_deref(),
5869 &self.config.payload_key_retired_env,
5870 self.config.payload_deployment_id.as_deref(),
5871 )?
5872 .map(Arc::new);
5873
5874 if self.cache.database.is_some() || self.config.postgres_cache_database_config.is_some() {
5876 let keys = payload_keys.as_deref().ok_or_else(|| {
5877 anyhow::anyhow!(
5878 "Postgres execution requires an active payload key and deployment identity"
5879 )
5880 })?;
5881
5882 if self.cache.database.is_none() {
5883 let pg_options = self
5884 .config
5885 .postgres_cache_database_config
5886 .as_ref()
5887 .expect("Postgres configuration checked above");
5888 let database = crate::cache::database::BlockchainCacheDatabase::connect(
5889 pg_options.clone().into(),
5890 )
5891 .await
5892 .map_err(|e| {
5893 anyhow::anyhow!("Failed to connect to the Postgres cache database: {e}")
5894 })?;
5895 self.cache.database = Some(database);
5896 }
5897 self.cache
5898 .database
5899 .as_ref()
5900 .expect("database was attached")
5901 .require_execution_payload_storage_ready(keys)
5902 .await?;
5903 self.cache.initialize_chain().await;
5904 self.cache.ensure_execution_transaction_schema().await?;
5905 let check = self
5906 .cache
5907 .database
5908 .as_ref()
5909 .expect("database was attached")
5910 .check_execution_payload_storage(
5911 Some(keys),
5912 Some(PayloadPolicy {
5913 chain_id: self.chain.chain_id,
5914 signer: self.wallet_address,
5915 gas_limit: self.config.gas_limit,
5916 max_fee_per_gas: self.config.max_fee_per_gas_wei,
5917 }),
5918 100,
5919 )
5920 .await?;
5921 anyhow::ensure!(
5922 check.protected,
5923 "Postgres execution requires protected payload storage"
5924 );
5925 } else {
5926 log::warn!(
5927 "No Postgres cache database configured; transactions will be refused (no durable store)"
5928 );
5929 }
5930 self.payload_keys = payload_keys;
5931
5932 let verification = self
5933 .config
5934 .verification
5935 .as_ref()
5936 .expect("verification config validated at construction");
5937 let position = if let Some(database) = self.cache.database.as_ref() {
5938 database
5939 .load_execution_verification_position(
5940 self.chain.chain_id,
5941 &self.config.wallet_address,
5942 &verification.manifest_version,
5943 &verification.manifest_digest,
5944 )
5945 .await?
5946 } else {
5947 None
5948 };
5949 let migration_snapshot = if position.is_none() {
5950 if let Some(database) = self.cache.database.as_ref() {
5951 let snapshot = database
5952 .load_execution_verification_migration_snapshot(
5953 self.chain.chain_id,
5954 &self.config.wallet_address,
5955 )
5956 .await?;
5957
5958 if snapshot.intents.is_empty() {
5959 None
5960 } else {
5961 Some(snapshot)
5962 }
5963 } else {
5964 None
5965 }
5966 } else {
5967 None
5968 };
5969
5970 let chain_id_verification = required_verification(
5971 self.verification.verify_chain_id().await,
5972 "Blockchain chain ID",
5973 )?;
5974 let checkpoint_verification = required_verification(
5975 self.verification.verify_checkpoint().await,
5976 "Blockchain checkpoint",
5977 )?;
5978 let checkpoint = checkpoint_verification.value;
5979 let finalized_verification = required_verification(
5980 self.verification.verify_finalized_header().await,
5981 "Blockchain finalized header",
5982 )?;
5983 let finalized = finalized_verification.value;
5984 let mut connect_decisions = vec![
5985 verification_decision(&chain_id_verification, None, None),
5986 verification_decision(
5987 &checkpoint_verification,
5988 Some(checkpoint.number),
5989 Some(checkpoint.number),
5990 ),
5991 verification_decision(
5992 &finalized_verification,
5993 Some(finalized.number),
5994 Some(finalized.number),
5995 ),
5996 ];
5997 let mut finalized_headers = if let Some(position) = position.as_ref() {
5998 let durable_tip = parse_verified_header(&position.finalized_tip)?;
5999 anyhow::ensure!(
6000 durable_tip.number >= checkpoint.number,
6001 "Durable finalized header tip precedes the trusted checkpoint"
6002 );
6003 let durable_tip_verification = required_verification(
6004 self.verification.verify_block(durable_tip.number).await,
6005 "Blockchain durable finalized tip",
6006 )?;
6007 anyhow::ensure!(
6008 durable_tip_verification.value == durable_tip,
6009 "Durable finalized header tip conflicts with independent sources"
6010 );
6011 connect_decisions.push(verification_decision(
6012 &durable_tip_verification,
6013 Some(durable_tip.number),
6014 Some(durable_tip.number),
6015 ));
6016 vec![durable_tip]
6017 } else {
6018 vec![checkpoint]
6019 };
6020 let mut ancestry_cursor = *finalized_headers
6021 .last()
6022 .expect("finalized header ledger is nonempty");
6023 anyhow::ensure!(
6024 finalized.number >= ancestry_cursor.number,
6025 "Verified finalized height regressed below the durable finalized header tip"
6026 );
6027
6028 while ancestry_cursor.number < finalized.number {
6029 let end = ancestry_cursor
6030 .number
6031 .saturating_add(4_096)
6032 .min(finalized.number);
6033 let start = ancestry_cursor.number.saturating_add(1);
6034 let headers_verification = required_verification(
6035 self.verification
6036 .verify_header_window(ancestry_cursor, end)
6037 .await,
6038 "Blockchain finalized ancestry",
6039 )?;
6040 let headers = &headers_verification.value;
6041 ancestry_cursor = *headers
6042 .last()
6043 .expect("nonempty ancestry window advances the cursor");
6044 finalized_headers.extend(headers.iter().copied());
6045 connect_decisions.push(verification_decision(
6046 &headers_verification,
6047 Some(start),
6048 Some(end),
6049 ));
6050 }
6051 anyhow::ensure!(
6052 ancestry_cursor == finalized,
6053 "Verified finalized header conflicts with its ancestry window"
6054 );
6055 let nonce_verification = required_verification(
6056 self.verification
6057 .verify_transaction_count(&self.wallet_address, finalized.number)
6058 .await,
6059 "Blockchain finalized transaction count",
6060 )?;
6061 let observed_canonical_nonce = nonce_verification.value;
6062 let next_canonical_nonce = position
6063 .as_ref()
6064 .map_or(observed_canonical_nonce, |position| {
6065 position.next_canonical_nonce
6066 });
6067
6068 if let Some(position) = position.as_ref() {
6069 log::debug!(
6070 "Resumed execution verification ledger at nonce revision {} with observed finalized nonce {}",
6071 position.revision,
6072 observed_canonical_nonce,
6073 );
6074 }
6075 connect_decisions.push(verification_decision(
6076 &nonce_verification,
6077 Some(finalized.number),
6078 Some(finalized.number),
6079 ));
6080 let deployment_verification = required_verification(
6081 self.verification
6082 .verify_deployment_manifest(&verification.deployment_manifest, finalized.number)
6083 .await,
6084 "Blockchain deployment manifest",
6085 )?;
6086 connect_decisions.push(verification_decision(
6087 &deployment_verification,
6088 Some(finalized.number),
6089 Some(finalized.number),
6090 ));
6091 connect_decisions.extend(
6092 verify_connect_capabilities(
6093 &self.verification,
6094 &verification.deployment_manifest,
6095 self.wallet_address,
6096 self.weth_address,
6097 finalized.number,
6098 )
6099 .await?,
6100 );
6101 let migration = if let Some(snapshot) = migration_snapshot {
6102 Some(
6103 self.build_execution_verification_migration(
6104 snapshot,
6105 finalized,
6106 &finalized_headers,
6107 &nonce_verification,
6108 )
6109 .await?,
6110 )
6111 } else {
6112 None
6113 };
6114
6115 if let Some(database) = self.cache.database.as_ref() {
6116 let identities = std::iter::once(&verification.authoritative)
6117 .chain(
6118 verification
6119 .verifiers
6120 .iter()
6121 .map(|provider| &provider.identity),
6122 )
6123 .collect::<Vec<_>>();
6124 let provider_ids = identities
6125 .iter()
6126 .map(|identity| identity.provider_id.clone())
6127 .collect::<Vec<_>>();
6128 let operator_ids = identities
6129 .iter()
6130 .map(|identity| identity.operator_id.clone())
6131 .collect::<Vec<_>>();
6132 let failure_domain_ids = identities
6133 .iter()
6134 .flat_map(|identity| identity.failure_domain_ids.iter().cloned())
6135 .collect::<Vec<_>>();
6136 let finalized_headers = finalized_headers
6137 .iter()
6138 .map(durable_verified_header)
6139 .collect::<Vec<_>>();
6140 database
6141 .ensure_execution_verification_schema(&ExecutionVerificationBootstrap {
6142 chain_id: self.chain.chain_id,
6143 wallet_address: &self.config.wallet_address,
6144 manifest_version: &verification.manifest_version,
6145 manifest_digest: &verification.manifest_digest,
6146 checkpoint_number: checkpoint.number,
6147 checkpoint_hash: &checkpoint.hash.to_string(),
6148 checkpoint_parent_hash: &checkpoint.parent_hash.to_string(),
6149 checkpoint_timestamp: checkpoint.timestamp,
6150 checkpoint_base_fee_per_gas: checkpoint.base_fee_per_gas,
6151 finalized_headers: &finalized_headers,
6152 next_canonical_nonce,
6153 observed_canonical_nonce,
6154 provider_ids: &provider_ids,
6155 operator_ids: &operator_ids,
6156 failure_domain_ids: &failure_domain_ids,
6157 decisions: &connect_decisions,
6158 migration: migration.as_ref(),
6159 })
6160 .await?;
6161 }
6162
6163 let payload_connect_lease = if let (Some(database), Some(keys)) =
6164 (self.cache.database.as_ref(), self.payload_keys.as_deref())
6165 {
6166 Some(
6167 database
6168 .require_execution_payload_storage(keys, self.payload_policy(), 100)
6169 .await?,
6170 )
6171 } else {
6172 None
6173 };
6174
6175 let private_key = Zeroizing::new(
6178 std::env::var(&self.config.signer_private_key_env).map_err(|_| {
6179 anyhow::anyhow!(
6180 "Signer private key environment variable '{}' is not set",
6181 self.config.signer_private_key_env
6182 )
6183 })?,
6184 );
6185 let encoded_key = private_key.trim();
6186 let encoded_key = encoded_key.strip_prefix("0x").unwrap_or(encoded_key);
6187 let key_bytes = Zeroizing::new(hex::decode_array::<32>(encoded_key).map_err(|_| {
6188 anyhow::anyhow!(
6189 "Signer private key in '{}' is not a valid hex private key",
6190 self.config.signer_private_key_env
6191 )
6192 })?);
6193 let signer = PrivateKeySigner::from_slice(&key_bytes[..]).map_err(|_| {
6194 anyhow::anyhow!(
6195 "Signer private key in '{}' is not a valid secp256k1 private key",
6196 self.config.signer_private_key_env
6197 )
6198 })?;
6199
6200 if signer.address() != self.wallet_address {
6201 anyhow::bail!(
6202 "Signer address {} derived from '{}' does not match configured wallet address {}",
6203 signer.address(),
6204 self.config.signer_private_key_env,
6205 self.wallet_address
6206 );
6207 }
6208
6209 self.signer = Some(Arc::new(signer));
6210 drop(payload_connect_lease);
6211
6212 if self.cache.has_database()
6213 && let Err(e) = self.reconcile_unresolved_execution().await
6214 {
6215 self.signer = None;
6216 return Err(e);
6217 }
6218
6219 if let Err(e) = self.refresh_wallet_balances().await {
6220 self.signer = None;
6221 return Err(e);
6222 }
6223 self.core.set_connected();
6224 setup_guard.disarm();
6225 log::info!(
6226 "Blockchain execution client connected on chain {}",
6227 self.chain.name
6228 );
6229 Ok(())
6230 }
6231
6232 async fn disconnect(&mut self) -> anyhow::Result<()> {
6233 self.pending_tasks.begin_shutdown();
6234 let tasks_result = self
6235 .pending_tasks
6236 .finish_shutdown(Duration::from_secs(5), Duration::from_secs(2))
6237 .await;
6238 self.signer = None;
6239 self.core.set_disconnected();
6240 tasks_result
6241 .map(|_| ())
6242 .map_err(|e| anyhow::anyhow!("Failed to terminate blockchain submissions: {e}"))?;
6243 Ok(())
6244 }
6245
6246 async fn generate_order_status_report(
6247 &self,
6248 _cmd: &GenerateOrderStatusReport,
6249 ) -> anyhow::Result<Option<OrderStatusReport>> {
6250 anyhow::bail!("{VENUE_EXECUTION_REPORTS_UNSUPPORTED}");
6251 }
6252
6253 async fn generate_order_status_reports(
6254 &self,
6255 _cmd: &GenerateOrderStatusReports,
6256 ) -> anyhow::Result<Vec<OrderStatusReport>> {
6257 anyhow::bail!("{VENUE_EXECUTION_REPORTS_UNSUPPORTED}");
6258 }
6259
6260 async fn generate_fill_reports(
6261 &self,
6262 _cmd: GenerateFillReports,
6263 ) -> anyhow::Result<Vec<FillReport>> {
6264 anyhow::bail!("{VENUE_EXECUTION_REPORTS_UNSUPPORTED}");
6265 }
6266
6267 async fn generate_position_status_reports(
6268 &self,
6269 _cmd: &GeneratePositionStatusReports,
6270 ) -> anyhow::Result<Vec<PositionStatusReport>> {
6271 anyhow::bail!("{VENUE_EXECUTION_REPORTS_UNSUPPORTED}");
6272 }
6273
6274 async fn generate_mass_status(
6275 &self,
6276 _lookback_mins: Option<u64>,
6277 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
6278 log::warn!(
6281 "Mass status is not supported on the blockchain execution client; skipping venue reconciliation"
6282 );
6283 Ok(None)
6284 }
6285}
6286
6287#[cfg(test)]
6288mod tests {
6289 use std::{
6290 cell::RefCell,
6291 rc::Rc,
6292 sync::atomic::{AtomicU64, Ordering},
6293 };
6294
6295 use alloy::{
6296 primitives::{address, aliases::I24},
6297 sol_types::SolValue,
6298 };
6299 use nautilus_common::{
6300 cache::Cache, live::runner::replace_exec_event_sender, messages::ExecutionEvent,
6301 testing::wait_until_async,
6302 };
6303 use nautilus_core::UUID4;
6304 use nautilus_infrastructure::sql::pg::{PostgresConnectOptions, get_postgres_connect_options};
6305 use nautilus_model::{
6306 defi::{
6307 PoolProfiler,
6308 chain::chains,
6309 data::block::BlockPosition,
6310 pool_analysis::{
6311 position::PoolPosition,
6312 snapshot::{PoolAnalytics, PoolSnapshot, PoolState},
6313 },
6314 tick_map::{tick::PoolTick, tick_math::get_tick_at_sqrt_ratio},
6315 },
6316 enums::AccountType,
6317 events::OrderEventAny,
6318 identifiers::{OrderListId, StrategyId, TraderId},
6319 orders::{OrderList, OrderTestBuilder},
6320 types::Price,
6321 };
6322 use rstest::rstest;
6323 use sqlx::postgres::{PgAdvisoryLock, PgAdvisoryLockKey, PgPoolOptions};
6324
6325 use super::*;
6326 use crate::{
6327 cache::database::tests::connect_test_database,
6328 config::{
6329 BlockchainCallEdgeManifest, BlockchainChainAnchorConfig, BlockchainContractManifest,
6330 BlockchainContractProbe, BlockchainContractRole, BlockchainDeploymentManifest,
6331 BlockchainPoolManifest, BlockchainProviderIdentity, BlockchainTokenManifest,
6332 BlockchainVerificationConfig, BlockchainVerificationProviderConfig, QuoteSpendLimit,
6333 },
6334 constants::BLOCKCHAIN_VENUE,
6335 exchanges::arbitrum::UNISWAP_V3,
6336 rpc::http::{
6337 EXECUTION_RPC_TIMEOUT_SECS,
6338 tests::mock::{MockRpcState, start_mock_rpc_server},
6339 },
6340 };
6341
6342 async fn poll_for_receipt(
6345 http_rpc_client: &BlockchainHttpRpcClient,
6346 tx_hash: &B256,
6347 max_polls: u32,
6348 interval: Duration,
6349 ) -> anyhow::Result<Option<RpcTransactionReceipt>> {
6350 let mut last_error = None;
6351 let mut observed_pending = false;
6352
6353 for attempt in 0..max_polls {
6354 if attempt > 0 {
6355 tokio::time::sleep(interval).await;
6356 }
6357
6358 match http_rpc_client.get_transaction_receipt(tx_hash).await {
6359 Ok(Some(receipt)) => return Ok(Some(receipt)),
6360 Ok(None) => observed_pending = true,
6361 Err(e) => {
6362 log::warn!(
6363 "Receipt poll {}/{} for transaction {tx_hash} failed: {e}",
6364 attempt + 1,
6365 max_polls
6366 );
6367 last_error = Some(e);
6368 }
6369 }
6370 }
6371
6372 if !observed_pending && let Some(e) = last_error {
6373 return Err(e);
6374 }
6375
6376 Ok(None)
6377 }
6378
6379 const CHAIN_ID_ARBITRUM: &str =
6380 include_str!("../../test_data/execution/rpc_eth_chain_id_arbitrum.json");
6381 const CHAIN_ID_ETHEREUM: &str =
6382 include_str!("../../test_data/execution/rpc_eth_chain_id_ethereum.json");
6383 const GET_CODE_DEPLOYED: &str =
6384 include_str!("../../test_data/execution/rpc_eth_get_code_deployed.json");
6385 const GET_CODE_EMPTY: &str =
6386 include_str!("../../test_data/execution/rpc_eth_get_code_empty.json");
6387 const GET_BALANCE: &str = include_str!("../../test_data/execution/rpc_eth_get_balance.json");
6388 const GET_BALANCE_ZERO: &str =
6389 include_str!("../../test_data/execution/rpc_eth_get_balance_zero.json");
6390 const GET_BALANCE_INSUFFICIENT: &str =
6391 include_str!("../../test_data/execution/rpc_eth_get_balance_insufficient.json");
6392 const CALL_BALANCE: &str = include_str!("../../test_data/execution/rpc_eth_call_balance.json");
6393 const CALL_BALANCE_AFTER_WRAP: &str =
6394 include_str!("../../test_data/execution/rpc_eth_call_balance_after_wrap.json");
6395 const CALL_BALANCE_WETH: &str =
6396 include_str!("../../test_data/execution/rpc_eth_call_balance_weth.json");
6397 const CALL_BALANCE_USDC: &str =
6398 include_str!("../../test_data/execution/rpc_eth_call_balance_usdc.json");
6399 const CALL_BALANCE_WETH_UPDATED: &str =
6400 include_str!("../../test_data/execution/rpc_eth_call_balance_weth_updated.json");
6401 const CALL_BALANCE_USDC_UPDATED: &str =
6402 include_str!("../../test_data/execution/rpc_eth_call_balance_usdc_updated.json");
6403 const CALL_BOOL_TRUE: &str =
6404 include_str!("../../test_data/execution/rpc_eth_call_bool_true.json");
6405 const CALL_EMPTY: &str = include_str!("../../test_data/execution/rpc_eth_call_empty.json");
6406 const CALL_ZERO: &str = include_str!("../../test_data/execution/rpc_eth_call_zero.json");
6407 const CALL_ALLOWANCE: &str =
6408 include_str!("../../test_data/execution/rpc_eth_call_allowance.json");
6409 const CALL_ALLOWANCE_1000: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x00000000000000000000000000000000000000000000000000000000000003e8\"}";
6410 const CALL_ALLOWANCE_MAX: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"}";
6411 const CALL_FACTORY: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x0000000000000000000000001f98431c8ad98523631ae4a59f267346ea31f984\"}";
6412 const CALL_WETH: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x00000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1\"}";
6413 const CALL_USDC: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831\"}";
6414 const CALL_FEE_500: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x00000000000000000000000000000000000000000000000000000000000001f4\"}";
6415 const CALL_POOL: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x000000000000000000000000c6962004f452be9203591991d15f6b388e09e8d0\"}";
6416 const CALL_REVERTED: &str =
6417 r#"{"jsonrpc":"2.0","id":1,"error":{"code":3,"message":"execution reverted"}}"#;
6418 const STORAGE_ZERO: &str = r#"{"jsonrpc":"2.0","id":1,"result":"0x0000000000000000000000000000000000000000000000000000000000000000"}"#;
6419 const TRACE_UNKNOWN_TRANSACTION: &str =
6420 r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"transaction not found"}}"#;
6421 const CALL_DECIMALS_18: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x0000000000000000000000000000000000000000000000000000000000000012\"}";
6422 const CALL_DECIMALS_6: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x0000000000000000000000000000000000000000000000000000000000000006\"}";
6423 const TRANSACTION_COUNT: &str =
6424 include_str!("../../test_data/execution/rpc_eth_get_transaction_count.json");
6425 const TRANSACTION_COUNT_NEXT: &str =
6426 include_str!("../../test_data/execution/rpc_eth_get_transaction_count_next.json");
6427 const ESTIMATE_GAS: &str = include_str!("../../test_data/execution/rpc_eth_estimate_gas.json");
6428 const MAX_PRIORITY_FEE: &str =
6429 include_str!("../../test_data/execution/rpc_eth_max_priority_fee_per_gas.json");
6430 const BLOCK_BY_NUMBER: &str =
6431 include_str!("../../test_data/execution/rpc_eth_get_block_by_number.json");
6432 const BLOCK_CANONICAL: &str =
6433 include_str!("../../test_data/execution/rpc_eth_get_block_canonical.json");
6434 const BLOCK_FINALIZED: &str =
6435 include_str!("../../test_data/execution/rpc_eth_get_block_finalized.json");
6436 const RECEIPT_SUCCESS: &str =
6437 include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_success.json");
6438 const RECEIPT_REVERTED: &str =
6439 include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_reverted.json");
6440 const RECEIPT_NULL: &str =
6441 include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_null.json");
6442 const SEND_RAW_TRANSACTION: &str =
6443 include_str!("../../test_data/execution/rpc_eth_send_raw_transaction.json");
6444 const SEND_RAW_TRANSACTION_REJECTED: &str =
6445 include_str!("../../test_data/execution/rpc_eth_send_raw_transaction_rejected.json");
6446 const SEND_RAW_TRANSACTION_NONCE_TOO_LOW: &str =
6447 include_str!("../../test_data/execution/rpc_eth_send_raw_transaction_nonce_too_low.json");
6448 const RPC_METHOD_NOT_FOUND: &str =
6449 include_str!("../../test_data/execution/rpc_error_method_not_found.json");
6450
6451 const WALLET: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
6452 const ROUTER: &str = "0xE592427A0AEce92De3Edee1F18E0157C05861564";
6453 const WETH: &str = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1";
6454 const USDC: &str = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";
6455 const LIVE_READ_SMOKE_ENV: &str = "BLOCKCHAIN_LIVE_READ_SMOKE";
6456 const LIVE_READ_SMOKE_RPC: &str = "https://arb1.arbitrum.io/rpc";
6457 const TEST_TIMEOUT: Duration = Duration::from_secs(10);
6458
6459 const WETH_ADDRESS: Address = address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1");
6460 const USDC_ADDRESS: Address = address!("af88d065e77c8cC2239327C5EDb3A432268e5831");
6461 const ROUTER_ADDRESS: Address = address!("E592427A0AEce92De3Edee1F18E0157C05861564");
6462
6463 const TEST_PRIVATE_KEY: &str =
6465 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
6466
6467 const BALANCE_OF_SELECTOR: &str = "0x70a08231";
6468 const ALLOWANCE_SELECTOR: &str = "0xdd62ed3e";
6469 const POOL_TOKEN0_SELECTOR: &str = "0x0dfe1681";
6470 const POOL_TOKEN1_SELECTOR: &str = "0xd21220a7";
6471 const POOL_FEE_SELECTOR: &str = "0xddca3f43";
6472 const DECIMALS_SELECTOR: &str = "0x313ce567";
6473 const FACTORY_SELECTOR: &str = "0xc45a0155";
6474 const WETH9_SELECTOR: &str = "0x4aa4a4fc";
6475 const GET_POOL_SELECTOR: &str = "0x1698ee82";
6476 const QUOTE_EXACT_INPUT_SELECTOR: &str = "0xc6a5026a";
6477 const QUOTE_EXACT_OUTPUT_SELECTOR: &str = "0xbd21704a";
6478
6479 fn test_pool() -> Pool {
6480 let chain = Arc::new(chains::ARBITRUM.clone());
6481 let dex = UNISWAP_V3.dex.clone();
6482 let weth = Token::new(
6483 chain.clone(),
6484 address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
6485 "Wrapped Ether".to_string(),
6486 "WETH".to_string(),
6487 18,
6488 );
6489 let usdc = Token::new(
6490 chain.clone(),
6491 address!("af88d065e77c8cC2239327C5EDb3A432268e5831"),
6492 "USD Coin".to_string(),
6493 "USDC".to_string(),
6494 6,
6495 );
6496
6497 Pool::new(
6498 chain,
6499 dex,
6500 address!("C6962004f452bE9203591991D15f6b388e09E8D0"),
6501 PoolIdentifier::from_address(address!("C6962004f452bE9203591991D15f6b388e09E8D0")),
6502 55_000_000,
6503 weth,
6504 usdc,
6505 Some(500),
6506 Some(10),
6507 UnixNanos::default(),
6508 )
6509 }
6510
6511 fn test_config(http_rpc_url: String) -> BlockchainExecutionClientConfig {
6512 test_config_with_signer_env(http_rpc_url, "BLOCKCHAIN_TEST_PRIVATE_KEY")
6513 }
6514
6515 fn test_config_with_signer_env(
6516 http_rpc_url: String,
6517 signer_env: &str,
6518 ) -> BlockchainExecutionClientConfig {
6519 let verifier_separator = if http_rpc_url.contains('?') { '&' } else { '?' };
6520 let code_hash = keccak256(
6521 hex::decode("6080604052348015600e575f5ffd5b5060").expect("valid test bytecode"),
6522 )
6523 .to_string();
6524 let result = |response: &str| {
6525 serde_json::from_str::<serde_json::Value>(response).unwrap()["result"]
6526 .as_str()
6527 .unwrap()
6528 .to_string()
6529 };
6530 let probe = |call_data: &str, expected_output: String| BlockchainContractProbe {
6531 call_data: call_data.to_string(),
6532 expected_output,
6533 };
6534 let contract = |address: &str, role| {
6535 let probes = match role {
6536 BlockchainContractRole::Router => vec![
6537 probe(FACTORY_SELECTOR, result(CALL_FACTORY)),
6538 probe(WETH9_SELECTOR, result(CALL_WETH)),
6539 ],
6540 BlockchainContractRole::Factory => vec![probe(
6541 &hex::encode_prefixed(
6542 UniswapV3Factory::getPoolCall {
6543 tokenA: WETH_ADDRESS,
6544 tokenB: USDC_ADDRESS,
6545 fee: U24::try_from(500u32).unwrap(),
6546 }
6547 .abi_encode(),
6548 ),
6549 result(CALL_POOL),
6550 )],
6551 BlockchainContractRole::WrappedNative => {
6552 vec![probe(DECIMALS_SELECTOR, result(CALL_DECIMALS_18))]
6553 }
6554 BlockchainContractRole::Quote => {
6555 vec![probe(FACTORY_SELECTOR, result(CALL_FACTORY))]
6556 }
6557 BlockchainContractRole::Token => {
6558 vec![probe(DECIMALS_SELECTOR, result(CALL_DECIMALS_6))]
6559 }
6560 BlockchainContractRole::Pool => vec![
6561 probe(POOL_TOKEN0_SELECTOR, result(CALL_WETH)),
6562 probe(POOL_TOKEN1_SELECTOR, result(CALL_USDC)),
6563 probe(POOL_FEE_SELECTOR, result(CALL_FEE_500)),
6564 ],
6565 BlockchainContractRole::Implementation => Vec::new(),
6566 };
6567 BlockchainContractManifest {
6568 address: address.to_string(),
6569 role,
6570 runtime_code_hash: code_hash.clone(),
6571 proxy: None,
6572 probes,
6573 }
6574 };
6575 let deployment_manifest = BlockchainDeploymentManifest {
6576 version: "test-v1".to_string(),
6577 chain_id: chains::ARBITRUM.chain_id,
6578 chain_name: chains::ARBITRUM.name.to_string(),
6579 contracts: vec![
6580 contract(ROUTER, BlockchainContractRole::Router),
6581 contract(
6582 "0x1F98431c8aD98523631AE4a59f267346ea31F984",
6583 BlockchainContractRole::Factory,
6584 ),
6585 contract(WETH, BlockchainContractRole::WrappedNative),
6586 contract(
6587 "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
6588 BlockchainContractRole::Quote,
6589 ),
6590 contract(USDC, BlockchainContractRole::Token),
6591 contract(
6592 "0xC6962004f452bE9203591991D15f6b388e09E8D0",
6593 BlockchainContractRole::Pool,
6594 ),
6595 ],
6596 tokens: vec![
6597 BlockchainTokenManifest {
6598 address: WETH.to_string(),
6599 name: "Wrapped Ether".to_string(),
6600 symbol: "WETH".to_string(),
6601 decimals: 18,
6602 asset_role: "both".to_string(),
6603 },
6604 BlockchainTokenManifest {
6605 address: USDC.to_string(),
6606 name: "USD Coin".to_string(),
6607 symbol: "USDC".to_string(),
6608 decimals: 6,
6609 asset_role: "both".to_string(),
6610 },
6611 ],
6612 pools: vec![BlockchainPoolManifest {
6613 address: "0xC6962004f452bE9203591991D15f6b388e09E8D0".to_string(),
6614 token0: WETH.to_string(),
6615 token1: USDC.to_string(),
6616 fee: 500,
6617 factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984".to_string(),
6618 quote_contract: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e".to_string(),
6619 }],
6620 call_edges: ["swap_sell", "swap_buy"]
6621 .into_iter()
6622 .map(|purpose| BlockchainCallEdgeManifest {
6623 purpose: purpose.to_string(),
6624 caller: ROUTER.to_string(),
6625 target: "0xC6962004f452bE9203591991D15f6b388e09E8D0".to_string(),
6626 call_type: "call".to_string(),
6627 })
6628 .collect(),
6629 };
6630 let manifest_digest =
6631 keccak256(serde_json::to_vec(&deployment_manifest).unwrap()).to_string();
6632 let verification = BlockchainVerificationConfig {
6633 authoritative: BlockchainProviderIdentity {
6634 provider_id: "authoritative".to_string(),
6635 operator_id: "operator-a".to_string(),
6636 failure_domain_ids: vec!["domain-a".to_string()],
6637 },
6638 verifiers: vec![
6639 BlockchainVerificationProviderConfig {
6640 identity: BlockchainProviderIdentity {
6641 provider_id: "verifier-a".to_string(),
6642 operator_id: "operator-b".to_string(),
6643 failure_domain_ids: vec!["domain-b".to_string()],
6644 },
6645 http_rpc_url: format!("{http_rpc_url}{verifier_separator}source=verifier-a"),
6646 },
6647 BlockchainVerificationProviderConfig {
6648 identity: BlockchainProviderIdentity {
6649 provider_id: "verifier-b".to_string(),
6650 operator_id: "operator-c".to_string(),
6651 failure_domain_ids: vec!["domain-c".to_string()],
6652 },
6653 http_rpc_url: format!("{http_rpc_url}{verifier_separator}source=verifier-b"),
6654 },
6655 ],
6656 chain_anchor: BlockchainChainAnchorConfig {
6657 chain_id: chains::ARBITRUM.chain_id,
6658 chain_name: chains::ARBITRUM.name.to_string(),
6659 checkpoint_height: 30_346_560,
6660 checkpoint_hash:
6661 "0x1111111111111111111111111111111111111111111111111111111111111111".to_string(),
6662 checkpoint_timestamp: 1_761_888_800,
6663 max_head_skew_blocks: 3,
6664 max_head_age_secs: u64::MAX,
6665 max_future_drift_secs: u64::MAX,
6666 },
6667 manifest_version: "test-v1".to_string(),
6668 manifest_digest,
6669 deployment_manifest,
6670 };
6671 BlockchainExecutionClientConfig::builder()
6672 .client_id(AccountId::from("BLOCKCHAIN-001"))
6673 .chain(chains::ARBITRUM.clone())
6674 .wallet_address(WALLET.to_string())
6675 .http_rpc_url(http_rpc_url)
6676 .verification(verification)
6677 .signer_private_key_env(signer_env.to_string())
6678 .router_addresses(vec![ROUTER.to_string()])
6679 .weth_address(WETH.to_string())
6680 .max_fee_per_gas_wei(1_000_000_000)
6681 .base_fee_buffer_bps(2_000)
6682 .gas_limit(1_000_000)
6683 .gas_buffer_bps(2_000)
6684 .allowed_token_pairs(vec![(WETH.to_string(), USDC.to_string())])
6685 .slippage_bps(50)
6686 .max_slippage_bps(200)
6687 .max_order_amount(1_000_000_000_000_000_000)
6688 .deadline_seconds(300)
6689 .max_quote_age_blocks(100)
6690 .receipt_timeout_secs(1)
6691 .build()
6692 }
6693
6694 fn buy_test_config(http_rpc_url: String) -> BlockchainExecutionClientConfig {
6695 let max_amount = expected_buy_amount_in().to_string();
6696 let mut config = test_config(http_rpc_url);
6697 config.allowed_token_pairs = Some(vec![
6698 (WETH.to_string(), USDC.to_string()),
6699 (USDC.to_string(), WETH.to_string()),
6700 ]);
6701 config.quote_spend_limits = Some(vec![quote_spend_limit(USDC, WETH, 6, &max_amount)]);
6702 config
6703 }
6704
6705 fn refresh_test_manifest_digest(config: &mut BlockchainExecutionClientConfig) {
6706 let verification = config.verification.as_mut().unwrap();
6707 verification.manifest_digest =
6708 keccak256(serde_json::to_vec(&verification.deployment_manifest).unwrap()).to_string();
6709 }
6710
6711 fn quote_spend_limit(
6712 token_in: &str,
6713 token_out: &str,
6714 spend_token_decimals: u8,
6715 max_amount: &str,
6716 ) -> QuoteSpendLimit {
6717 QuoteSpendLimit::builder()
6718 .token_in(token_in.to_string())
6719 .token_out(token_out.to_string())
6720 .spend_token(token_in.to_string())
6721 .spend_token_decimals(spend_token_decimals)
6722 .max_amount(max_amount.to_string())
6723 .build()
6724 }
6725
6726 fn test_client_from_config(
6727 config: BlockchainExecutionClientConfig,
6728 pool: Pool,
6729 ) -> BlockchainExecutionClient {
6730 test_client_result(config, pool).unwrap()
6731 }
6732
6733 fn test_client_result(
6734 config: BlockchainExecutionClientConfig,
6735 pool: Pool,
6736 ) -> anyhow::Result<BlockchainExecutionClient> {
6737 test_client_and_cache(config, pool).map(|(client, _)| client)
6738 }
6739
6740 fn test_client_and_cache(
6741 config: BlockchainExecutionClientConfig,
6742 pool: Pool,
6743 ) -> anyhow::Result<(BlockchainExecutionClient, Rc<RefCell<Cache>>)> {
6744 let cache = Rc::new(RefCell::new(Cache::default()));
6745 cache.borrow_mut().add_pool(pool).unwrap();
6746 let core = ExecutionClientCore::new(
6747 TraderId::from("TRADER-001"),
6748 ClientId::from("BLOCKCHAIN-001"),
6749 *BLOCKCHAIN_VENUE,
6750 OmsType::Netting,
6751 AccountId::from("BLOCKCHAIN-001"),
6752 AccountType::Wallet,
6753 None,
6754 cache.clone(),
6755 );
6756
6757 let client = BlockchainExecutionClient::new(core, config)?;
6758 Ok((client, cache))
6759 }
6760
6761 fn test_client(http_rpc_url: String) -> BlockchainExecutionClient {
6762 test_client_from_config(test_config(http_rpc_url), test_pool())
6763 }
6764
6765 async fn client_with_mock_rpc(
6766 state: MockRpcState,
6767 ) -> (BlockchainExecutionClient, MockRpcState) {
6768 let addr = start_mock_rpc_server(state.clone()).await;
6769 (test_client(format!("http://{addr}")), state)
6770 }
6771
6772 async fn client_with_token_mock_rpc(
6773 state: MockRpcState,
6774 signer_env: &str,
6775 ) -> (BlockchainExecutionClient, MockRpcState, Rc<RefCell<Cache>>) {
6776 let state = with_connect_capabilities(state);
6777 let addr = start_mock_rpc_server(state.clone()).await;
6778 let pool = test_pool();
6779 let tokens = [pool.token0.clone(), pool.token1.clone()];
6780 let mut config = test_config_with_signer_env(format!("http://{addr}"), signer_env);
6781 config.tokens = Some(vec![WETH.to_string(), USDC.to_string()]);
6782 let (mut client, cache) = test_client_and_cache(config, pool).unwrap();
6783 for token in tokens {
6784 client.cache.add_token(token).await.unwrap();
6785 }
6786 (client, state, cache)
6787 }
6788
6789 fn execution_rpc_state() -> MockRpcState {
6790 MockRpcState::default()
6791 .with_receipt_hash_from_request()
6792 .with_response("eth_chainId", CHAIN_ID_ARBITRUM)
6793 .with_response("eth_getCode", GET_CODE_DEPLOYED)
6794 .with_response("eth_getBalance", GET_BALANCE)
6795 .with_response("eth_getBlockByNumber", BLOCK_BY_NUMBER)
6796 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
6797 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", BLOCK_CANONICAL)
6798 .with_parameter_response("eth_getBlockByNumber", "finalized", BLOCK_FINALIZED)
6799 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d42", BLOCK_FINALIZED)
6800 .with_response("eth_maxPriorityFeePerGas", MAX_PRIORITY_FEE)
6801 .with_call_response(FACTORY_SELECTOR, CALL_FACTORY)
6802 .with_call_response(WETH9_SELECTOR, CALL_WETH)
6803 .with_call_response(GET_POOL_SELECTOR, CALL_POOL)
6804 .with_call_response(POOL_TOKEN0_SELECTOR, CALL_WETH)
6805 .with_call_response(POOL_TOKEN1_SELECTOR, CALL_USDC)
6806 .with_call_response(POOL_FEE_SELECTOR, CALL_FEE_500)
6807 .with_contract_call_response(WETH, DECIMALS_SELECTOR, CALL_DECIMALS_18)
6808 .with_contract_call_response(USDC, DECIMALS_SELECTOR, CALL_DECIMALS_6)
6809 .with_call_response(
6810 QUOTE_EXACT_INPUT_SELECTOR,
6811 "e_response(expected_sell_quote_amount()),
6812 )
6813 .with_call_response(
6814 QUOTE_EXACT_OUTPUT_SELECTOR,
6815 "e_response(expected_buy_amount_in()),
6816 )
6817 }
6818
6819 fn ready_rpc_state() -> MockRpcState {
6820 with_connect_capabilities(execution_rpc_state())
6821 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE)
6822 .with_call_response(ALLOWANCE_SELECTOR, CALL_ALLOWANCE)
6823 }
6824
6825 fn with_connect_capabilities(state: MockRpcState) -> MockRpcState {
6826 state
6827 .with_response("eth_getStorageAt", STORAGE_ZERO)
6828 .with_response("eth_estimateGas", ESTIMATE_GAS)
6829 .with_parameter_response(
6830 "debug_traceTransaction",
6831 &B256::ZERO.to_string(),
6832 TRACE_UNKNOWN_TRANSACTION,
6833 )
6834 }
6835
6836 fn signing_rpc_state() -> MockRpcState {
6837 ready_rpc_state()
6838 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
6839 .with_response("eth_estimateGas", ESTIMATE_GAS)
6840 }
6841
6842 fn broadcast_rpc_state() -> MockRpcState {
6843 execution_rpc_state()
6844 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
6845 .with_response("eth_estimateGas", ESTIMATE_GAS)
6846 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS)
6847 .with_send_raw_transaction_echo()
6848 .with_call_response_sequence(ALLOWANCE_SELECTOR, &[CALL_ZERO, CALL_ALLOWANCE_1000])
6849 }
6850
6851 async fn expected_wrap_tx_hash(value: U256) -> B256 {
6852 expected_tx_hash(
6853 WETH_ADDRESS,
6854 value,
6855 Bytes::from(nautilus_core::hex::decode("d0e30db0").unwrap()),
6856 )
6857 .await
6858 }
6859
6860 async fn expected_approve_tx_hash(amount: U256) -> B256 {
6861 let calldata = ERC20::approveCall {
6862 spender: ROUTER_ADDRESS,
6863 amount,
6864 }
6865 .abi_encode();
6866 expected_tx_hash(WETH_ADDRESS, U256::ZERO, Bytes::from(calldata)).await
6867 }
6868
6869 async fn expected_tx_hash(to: Address, value: U256, input: Bytes) -> B256 {
6870 let expected_tx =
6873 build_eip1559_transaction(42161, 7, 78_000, 130_000_000, 10_000_000, to, value, input);
6874 let (expected_hash, _) = sign_eip1559_transaction(
6875 expected_tx,
6876 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
6877 )
6878 .await
6879 .unwrap();
6880 expected_hash
6881 }
6882
6883 async fn await_recorded_requests(state: &MockRpcState, method: &str, expected: usize) {
6884 wait_until_async(
6885 || async {
6886 state
6887 .recorded_requests()
6888 .iter()
6889 .filter(|request| request["method"] == method)
6890 .count()
6891 >= expected
6892 },
6893 TEST_TIMEOUT,
6894 )
6895 .await;
6896 }
6897
6898 const FIXTURE_BLOCK: u64 = 30_346_560;
6901 const FIXTURE_BLOCK_PARAM: &str = "0x1cf0d40";
6902 const FIXTURE_BLOCK_HASH: &str =
6903 "0x1111111111111111111111111111111111111111111111111111111111111111";
6904 const FIXTURE_BLOCK_TIMESTAMP: u64 = 1_761_888_800;
6906 const TEST_LIQUIDITY: u128 = 1_000_000_000_000_000_000_000;
6908
6909 fn test_profiler(pool: &Pool, block_number: u64) -> PoolProfiler {
6910 test_profiler_at_block(pool, block_number, FIXTURE_BLOCK_HASH)
6911 }
6912
6913 fn test_profiler_at_block(pool: &Pool, block_number: u64, block_hash: &str) -> PoolProfiler {
6914 test_profiler_with_range(
6915 pool,
6916 block_number,
6917 block_hash,
6918 U160::from(1u128 << 96),
6919 -887_220,
6920 887_220,
6921 TEST_LIQUIDITY,
6922 )
6923 }
6924
6925 fn test_profiler_with_state(
6926 pool: &Pool,
6927 block_number: u64,
6928 sqrt_price_x96: U160,
6929 liquidity: u128,
6930 ) -> PoolProfiler {
6931 test_profiler_with_range(
6932 pool,
6933 block_number,
6934 FIXTURE_BLOCK_HASH,
6935 sqrt_price_x96,
6936 -887_220,
6937 887_220,
6938 liquidity,
6939 )
6940 }
6941
6942 fn test_profiler_with_range(
6946 pool: &Pool,
6947 block_number: u64,
6948 block_hash: &str,
6949 sqrt_price_x96: U160,
6950 tick_lower: i32,
6951 tick_upper: i32,
6952 liquidity: u128,
6953 ) -> PoolProfiler {
6954 let snapshot = PoolSnapshot::new(
6955 pool.instrument_id,
6956 PoolState {
6957 current_tick: get_tick_at_sqrt_ratio(sqrt_price_x96),
6958 price_sqrt_ratio_x96: sqrt_price_x96,
6959 liquidity,
6960 protocol_fees_token0: U256::ZERO,
6961 protocol_fees_token1: U256::ZERO,
6962 fee_protocol: 0,
6963 fee_protocol0_basis_points: None,
6964 fee_protocol1_basis_points: None,
6965 fee_growth_global_0: U256::ZERO,
6966 fee_growth_global_1: U256::ZERO,
6967 },
6968 vec![PoolPosition::new(
6969 address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"),
6970 tick_lower,
6971 tick_upper,
6972 liquidity as i128,
6973 )],
6974 vec![
6975 PoolTick::new(
6976 tick_lower,
6977 liquidity,
6978 liquidity as i128,
6979 U256::ZERO,
6980 U256::ZERO,
6981 true,
6982 0,
6983 ),
6984 PoolTick::new(
6985 tick_upper,
6986 liquidity,
6987 -(liquidity as i128),
6988 U256::ZERO,
6989 U256::ZERO,
6990 true,
6991 0,
6992 ),
6993 ],
6994 PoolAnalytics::default(),
6995 BlockPosition::new(
6996 block_number,
6997 block_hash.to_string(),
6998 BLOCK_SCOPED_SNAPSHOT_INDEX,
6999 BLOCK_SCOPED_SNAPSHOT_INDEX,
7000 )
7001 .with_block_hash(Some(block_hash.to_string())),
7002 UnixNanos::default(),
7003 UnixNanos::default(),
7004 );
7005 let mut profiler = PoolProfiler::new(Arc::new(pool.clone()));
7006 profiler.restore_from_snapshot(snapshot).unwrap();
7007 profiler
7008 }
7009
7010 fn test_market_sell_order(instrument_id: InstrumentId) -> OrderAny {
7011 market_sell_order_with_id(instrument_id, "O-SWAP-001")
7012 }
7013
7014 fn test_market_buy_order(instrument_id: InstrumentId) -> OrderAny {
7015 market_buy_order_with_id(instrument_id, "O-SWAP-BUY-001")
7016 }
7017
7018 fn submit_order_cmd(order: &OrderAny) -> SubmitOrder {
7019 SubmitOrder::new(
7020 TraderId::from("TRADER-001"),
7021 Some(ClientId::from("BLOCKCHAIN-001")),
7022 order.strategy_id(),
7023 order.instrument_id(),
7024 order.client_order_id(),
7025 order.init_event().clone(),
7026 None,
7027 None,
7028 None,
7029 UUID4::new(),
7030 UnixNanos::default(),
7031 None,
7032 )
7033 }
7034
7035 fn swap_client_with_cache(
7038 config: BlockchainExecutionClientConfig,
7039 ) -> (BlockchainExecutionClient, Rc<RefCell<Cache>>) {
7040 let cache = Rc::new(RefCell::new(Cache::default()));
7041 let pool = test_pool();
7042 cache.borrow_mut().add_pool(pool.clone()).unwrap();
7043 cache
7044 .borrow_mut()
7045 .add_order(
7046 test_market_sell_order(pool.instrument_id),
7047 None,
7048 None,
7049 false,
7050 )
7051 .unwrap();
7052 cache
7053 .borrow_mut()
7054 .add_pool_profiler(test_profiler_at_block(
7055 &pool,
7056 FIXTURE_BLOCK,
7057 FIXTURE_BLOCK_HASH,
7058 ))
7059 .unwrap();
7060 let core = ExecutionClientCore::new(
7061 TraderId::from("TRADER-001"),
7062 ClientId::from("BLOCKCHAIN-001"),
7063 *BLOCKCHAIN_VENUE,
7064 OmsType::Netting,
7065 AccountId::from("BLOCKCHAIN-001"),
7066 AccountType::Wallet,
7067 None,
7068 cache.clone(),
7069 );
7070
7071 let client = BlockchainExecutionClient::new(core, config).unwrap();
7072 (client, cache)
7073 }
7074
7075 async fn swap_client_with_database(
7077 test_name: &str,
7078 state: MockRpcState,
7079 ) -> Option<(
7080 sqlx::PgPool,
7081 String,
7082 BlockchainExecutionClient,
7083 MockRpcState,
7084 Rc<RefCell<Cache>>,
7085 )> {
7086 swap_client_with_database_config(test_name, state, test_config).await
7087 }
7088
7089 async fn swap_client_with_buy_database(
7090 test_name: &str,
7091 state: MockRpcState,
7092 ) -> Option<(
7093 sqlx::PgPool,
7094 String,
7095 BlockchainExecutionClient,
7096 MockRpcState,
7097 Rc<RefCell<Cache>>,
7098 )> {
7099 swap_client_with_database_config(test_name, state, buy_test_config).await
7100 }
7101
7102 async fn swap_client_with_database_config<F>(
7103 test_name: &str,
7104 state: MockRpcState,
7105 config: F,
7106 ) -> Option<(
7107 sqlx::PgPool,
7108 String,
7109 BlockchainExecutionClient,
7110 MockRpcState,
7111 Rc<RefCell<Cache>>,
7112 )>
7113 where
7114 F: FnOnce(String) -> BlockchainExecutionClientConfig,
7115 {
7116 let (admin_pool, pg_config) = connect_test_postgres(test_name).await?;
7117 let schema = format!("{test_name}_{}", std::process::id());
7118 setup_execution_schema(&admin_pool, &schema).await;
7119
7120 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
7121 let db_options = db_options.options([("search_path", schema.clone())]);
7122 let database = connect_test_database(db_options).await.unwrap();
7123 let addr = start_mock_rpc_server(state.clone()).await;
7124 let (mut client, cache) = swap_client_with_cache(config(format!("http://{addr}")));
7125 client.cache.database = Some(database);
7126 client
7128 .cache
7129 .ensure_execution_transaction_schema()
7130 .await
7131 .unwrap();
7132 protect_test_storage(&mut client, &schema).await;
7133 initialize_test_verification_ledger(&client).await;
7134 client.signer = Some(Arc::new(
7135 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7136 ));
7137 client.core.set_connected();
7138
7139 Some((admin_pool, schema, client, state, cache))
7140 }
7141
7142 async fn initialize_test_verification_ledger(client: &BlockchainExecutionClient) {
7143 initialize_test_verification_ledger_with_headers(
7144 client,
7145 &[ExecutionVerifiedHeader {
7146 number: FIXTURE_BLOCK,
7147 hash: FIXTURE_BLOCK_HASH.to_string(),
7148 parent_hash: "0x0000000000000000000000000000000000000000000000000000000000000001"
7149 .to_string(),
7150 timestamp: FIXTURE_BLOCK_TIMESTAMP,
7151 base_fee_per_gas: Some(100_000_000),
7152 }],
7153 )
7154 .await;
7155 }
7156
7157 async fn initialize_test_verification_ledger_with_headers(
7158 client: &BlockchainExecutionClient,
7159 finalized_headers: &[ExecutionVerifiedHeader],
7160 ) {
7161 ensure_test_verification_ledger(client, finalized_headers, 7, 7)
7162 .await
7163 .unwrap();
7164 }
7165
7166 async fn ensure_test_verification_ledger(
7167 client: &BlockchainExecutionClient,
7168 finalized_headers: &[ExecutionVerifiedHeader],
7169 next_canonical_nonce: u64,
7170 observed_canonical_nonce: u64,
7171 ) -> anyhow::Result<()> {
7172 let verification = client.config.verification.as_ref().unwrap();
7173 let provider_ids = vec![
7174 "authoritative".to_string(),
7175 "verifier-a".to_string(),
7176 "verifier-b".to_string(),
7177 ];
7178 let operator_ids = vec![
7179 "operator-a".to_string(),
7180 "operator-b".to_string(),
7181 "operator-c".to_string(),
7182 ];
7183 let failure_domain_ids = vec![
7184 "domain-a".to_string(),
7185 "domain-b".to_string(),
7186 "domain-c".to_string(),
7187 ];
7188 let decisions = [ExecutionVerificationDecision {
7189 read_class: "numbered_block",
7190 height_start: Some(FIXTURE_BLOCK),
7191 height_end: Some(FIXTURE_BLOCK),
7192 normalized_value_digest: B256::ZERO.to_string(),
7193 }];
7194 client
7195 .cache
7196 .database
7197 .as_ref()
7198 .unwrap()
7199 .ensure_execution_verification_schema(&ExecutionVerificationBootstrap {
7200 chain_id: 42_161,
7201 wallet_address: WALLET,
7202 manifest_version: &verification.manifest_version,
7203 manifest_digest: &verification.manifest_digest,
7204 checkpoint_number: FIXTURE_BLOCK,
7205 checkpoint_hash: FIXTURE_BLOCK_HASH,
7206 checkpoint_parent_hash:
7207 "0x0000000000000000000000000000000000000000000000000000000000000001",
7208 checkpoint_timestamp: FIXTURE_BLOCK_TIMESTAMP,
7209 checkpoint_base_fee_per_gas: Some(100_000_000),
7210 finalized_headers,
7211 next_canonical_nonce,
7212 observed_canonical_nonce,
7213 provider_ids: &provider_ids,
7214 operator_ids: &operator_ids,
7215 failure_domain_ids: &failure_domain_ids,
7216 decisions: &decisions,
7217 migration: None,
7218 })
7219 .await
7220 }
7221
7222 async fn initialize_test_verification_migration(
7223 client: &BlockchainExecutionClient,
7224 finalized_headers: &[ExecutionVerifiedHeader],
7225 next_canonical_nonce: u64,
7226 decisions: &[ExecutionVerificationDecision],
7227 migration: &ExecutionVerificationMigration,
7228 ) {
7229 let verification = client.config.verification.as_ref().unwrap();
7230 let provider_ids = vec![
7231 "authoritative".to_string(),
7232 "verifier-a".to_string(),
7233 "verifier-b".to_string(),
7234 ];
7235 let operator_ids = vec![
7236 "operator-a".to_string(),
7237 "operator-b".to_string(),
7238 "operator-c".to_string(),
7239 ];
7240 let failure_domain_ids = vec![
7241 "domain-a".to_string(),
7242 "domain-b".to_string(),
7243 "domain-c".to_string(),
7244 ];
7245 client
7246 .cache
7247 .database
7248 .as_ref()
7249 .unwrap()
7250 .ensure_execution_verification_schema(&ExecutionVerificationBootstrap {
7251 chain_id: 42_161,
7252 wallet_address: WALLET,
7253 manifest_version: &verification.manifest_version,
7254 manifest_digest: &verification.manifest_digest,
7255 checkpoint_number: FIXTURE_BLOCK,
7256 checkpoint_hash: FIXTURE_BLOCK_HASH,
7257 checkpoint_parent_hash:
7258 "0x0000000000000000000000000000000000000000000000000000000000000001",
7259 checkpoint_timestamp: FIXTURE_BLOCK_TIMESTAMP,
7260 checkpoint_base_fee_per_gas: Some(100_000_000),
7261 finalized_headers,
7262 next_canonical_nonce,
7263 observed_canonical_nonce: next_canonical_nonce,
7264 provider_ids: &provider_ids,
7265 operator_ids: &operator_ids,
7266 failure_domain_ids: &failure_domain_ids,
7267 decisions,
7268 migration: Some(migration),
7269 })
7270 .await
7271 .unwrap();
7272 }
7273
7274 async fn swap_rpc_state() -> MockRpcState {
7275 let min_amount_out = expected_min_amount_out(50);
7276 swap_rpc_state_with_min_amount_out(min_amount_out).await
7277 }
7278
7279 async fn swap_rpc_state_with_min_amount_out(min_amount_out: U256) -> MockRpcState {
7280 let (tx_hash, _) = expected_swap_tx(min_amount_out).await;
7281 finalized_swap_rpc_state(tx_hash, min_amount_out)
7282 }
7283
7284 async fn swap_rpc_state_for_mismatch() -> MockRpcState {
7286 swap_rpc_state()
7287 .await
7288 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION)
7289 }
7290
7291 fn awaiting_in_flight(client: &BlockchainExecutionClient) -> InFlightTransaction {
7293 let slot = *client.in_flight.lock();
7294 let Some(InFlightSlot::AwaitingFinality(in_flight)) = slot else {
7295 panic!("expected an awaiting-finality transaction, was {slot:?}");
7296 };
7297 in_flight
7298 }
7299
7300 fn recovering_in_flight(client: &BlockchainExecutionClient) -> RecoveryTransaction {
7301 let slot = *client.in_flight.lock();
7302 let Some(InFlightSlot::Recovering(recovery)) = slot else {
7303 panic!("expected a recovery transaction, was {slot:?}");
7304 };
7305 recovery
7306 }
7307
7308 async fn execution_intent_markers(
7309 admin_pool: &sqlx::PgPool,
7310 schema: &str,
7311 ) -> Vec<(String, String, bool, bool)> {
7312 sqlx::query_as(sqlx::AssertSqlSafe(format!(
7313 "SELECT purpose, status, terminal_emitted, active \
7314 FROM {schema}.execution_intent ORDER BY id"
7315 )))
7316 .fetch_all(admin_pool)
7317 .await
7318 .unwrap()
7319 }
7320
7321 #[allow(unsafe_code)] fn payload_test_keys(
7323 active: [u8; 32],
7324 retired: Vec<[u8; 32]>,
7325 deployment_id: &str,
7326 ) -> PayloadKeySet {
7327 static NEXT_ENV_ID: AtomicU64 = AtomicU64::new(0);
7328
7329 let env_id = NEXT_ENV_ID.fetch_add(1, Ordering::Relaxed);
7330 let active_env = format!("BLOCKCHAIN_TEST_PAYLOAD_KEY_{env_id}_ACTIVE");
7331 let retired_envs = retired
7332 .iter()
7333 .enumerate()
7334 .map(|(index, _)| format!("BLOCKCHAIN_TEST_PAYLOAD_KEY_{env_id}_RETIRED_{index}"))
7335 .collect::<Vec<_>>();
7336 unsafe { std::env::set_var(&active_env, hex::encode(active)) };
7338 for (env, key) in retired_envs.iter().zip(&retired) {
7339 unsafe { std::env::set_var(env, hex::encode(key)) };
7341 }
7342
7343 let keys = PayloadKeySet::load(Some(&active_env), &retired_envs, Some(deployment_id))
7344 .unwrap()
7345 .unwrap();
7346
7347 unsafe { std::env::remove_var(active_env) };
7349 for env in retired_envs {
7350 unsafe { std::env::remove_var(env) };
7352 }
7353 keys
7354 }
7355
7356 #[allow(unsafe_code)] fn set_test_payload_key(
7358 client: &mut BlockchainExecutionClient,
7359 key: [u8; 32],
7360 deployment_id: &str,
7361 ) -> PayloadKeySet {
7362 static NEXT_ENV_ID: AtomicU64 = AtomicU64::new(0);
7363
7364 let env_id = NEXT_ENV_ID.fetch_add(1, Ordering::Relaxed);
7365 let active_env = format!("BLOCKCHAIN_TEST_EXECUTION_KEY_{env_id}");
7366 unsafe { std::env::set_var(&active_env, hex::encode(key)) };
7368 client.config.payload_key_env = Some(active_env);
7369 client.config.payload_deployment_id = Some(deployment_id.to_string());
7370 client.load_payload_keys().unwrap().unwrap()
7371 }
7372
7373 async fn protect_test_storage(client: &mut BlockchainExecutionClient, deployment_id: &str) {
7374 let keys = set_test_payload_key(client, [0xa5; 32], deployment_id);
7375 client
7376 .cache
7377 .database
7378 .as_ref()
7379 .unwrap()
7380 .ensure_execution_payload_storage(&keys)
7381 .await
7382 .unwrap();
7383 client.payload_keys = Some(Arc::new(keys));
7384 }
7385
7386 async fn reserve_test_wrap_intent(database: &BlockchainCacheDatabase) -> ExecutionIntentRow {
7387 reserve_test_wrap_intent_for_wallet(database, WALLET).await
7388 }
7389
7390 async fn reserve_test_wrap_intent_for_wallet(
7391 database: &BlockchainCacheDatabase,
7392 wallet: &str,
7393 ) -> ExecutionIntentRow {
7394 database
7395 .reserve_execution_intent(&ExecutionIntentInsert {
7396 chain_id: 42161,
7397 wallet_address: wallet.to_string(),
7398 purpose: "wrap".to_string(),
7399 client_order_id: None,
7400 trader_id: None,
7401 strategy_id: None,
7402 account_id: None,
7403 instrument_id: None,
7404 pool_address: None,
7405 transaction_to: WETH_ADDRESS.to_string(),
7406 transaction_input: "0xd0e30db0".to_string(),
7407 transaction_value: "1".to_string(),
7408 amount_in: None,
7409 created_block: FIXTURE_BLOCK,
7410 })
7411 .await
7412 .unwrap()
7413 }
7414
7415 async fn persist_test_wrap_broadcast(
7416 database: &BlockchainCacheDatabase,
7417 keys: Option<&PayloadKeySet>,
7418 ) -> (ExecutionIntentRow, B256, Vec<u8>) {
7419 let intent = reserve_test_wrap_intent(database).await;
7420 database
7421 .assign_execution_intent_nonce(intent.id, 7)
7422 .await
7423 .unwrap();
7424 let intent = database.get_execution_intent(intent.id).await.unwrap();
7425 let transaction = build_eip1559_transaction(
7426 42161,
7427 7,
7428 78_000,
7429 130_000_000,
7430 10_000_000,
7431 WETH_ADDRESS,
7432 U256::from(1u64),
7433 Bytes::from(nautilus_core::hex::decode("d0e30db0").unwrap()),
7434 );
7435 let (tx_hash, raw_tx) = sign_eip1559_transaction(
7436 transaction,
7437 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7438 )
7439 .await
7440 .unwrap();
7441 persist_test_payload(database, keys, &intent, tx_hash, &raw_tx).await;
7442 database
7443 .record_execution_status(
7444 intent.id,
7445 &tx_hash.to_string(),
7446 TransactionStatus::Broadcast,
7447 None,
7448 None,
7449 None,
7450 None,
7451 None,
7452 )
7453 .await
7454 .unwrap();
7455 (intent, tx_hash, raw_tx)
7456 }
7457
7458 async fn reserve_test_swap_intent(database: &BlockchainCacheDatabase) -> ExecutionIntentRow {
7459 let pool = test_pool();
7460 let order = test_market_sell_order(pool.instrument_id);
7461 let calldata = expected_swap_calldata(expected_min_amount_out(50));
7462
7463 database
7464 .reserve_execution_intent(&ExecutionIntentInsert {
7465 chain_id: 42161,
7466 wallet_address: WALLET.to_string(),
7467 purpose: "swap".to_string(),
7468 client_order_id: Some(order.client_order_id().to_string()),
7469 trader_id: Some(order.trader_id().to_string()),
7470 strategy_id: Some(order.strategy_id().to_string()),
7471 account_id: Some("BLOCKCHAIN-001".to_string()),
7472 instrument_id: Some(pool.instrument_id.to_string()),
7473 pool_address: Some(pool.address.to_string()),
7474 transaction_to: ROUTER_ADDRESS.to_string(),
7475 transaction_input: hex::encode_prefixed(&calldata),
7476 transaction_value: U256::ZERO.to_string(),
7477 amount_in: Some("1000000000000000".to_string()),
7478 created_block: FIXTURE_BLOCK,
7479 })
7480 .await
7481 .unwrap()
7482 }
7483
7484 async fn persist_invalid_test_swap(
7485 database: &BlockchainCacheDatabase,
7486 keys: Option<&PayloadKeySet>,
7487 ) -> (ExecutionIntentRow, B256) {
7488 let intent = reserve_test_swap_intent(database).await;
7489 database
7490 .assign_execution_intent_nonce(intent.id, 7)
7491 .await
7492 .unwrap();
7493 let intent = database.get_execution_intent(intent.id).await.unwrap();
7494 let (tx_hash, raw_transaction) = expected_swap_tx(expected_min_amount_out(50)).await;
7495 let mut raw_transaction = hex::decode(raw_transaction.strip_prefix("0x").unwrap()).unwrap();
7496 raw_transaction.push(0xff);
7497 persist_test_payload(database, keys, &intent, tx_hash, &raw_transaction).await;
7498 database
7499 .record_execution_status(
7500 intent.id,
7501 &tx_hash.to_string(),
7502 TransactionStatus::Broadcast,
7503 None,
7504 None,
7505 None,
7506 None,
7507 None,
7508 )
7509 .await
7510 .unwrap();
7511
7512 (intent, tx_hash)
7513 }
7514
7515 async fn persist_test_swap_broadcast(
7516 database: &BlockchainCacheDatabase,
7517 keys: Option<&PayloadKeySet>,
7518 ) -> (ExecutionIntentRow, B256, Vec<u8>) {
7519 let intent = reserve_test_swap_intent(database).await;
7520 database
7521 .assign_execution_intent_nonce(intent.id, 7)
7522 .await
7523 .unwrap();
7524 let intent = database.get_execution_intent(intent.id).await.unwrap();
7525 let (tx_hash, raw_transaction) = expected_swap_tx(expected_min_amount_out(50)).await;
7526 let raw_transaction = hex::decode(raw_transaction.strip_prefix("0x").unwrap()).unwrap();
7527 persist_test_payload(database, keys, &intent, tx_hash, &raw_transaction).await;
7528 database
7529 .record_execution_status(
7530 intent.id,
7531 &tx_hash.to_string(),
7532 TransactionStatus::Broadcast,
7533 None,
7534 None,
7535 None,
7536 None,
7537 None,
7538 )
7539 .await
7540 .unwrap();
7541 (intent, tx_hash, raw_transaction)
7542 }
7543
7544 async fn persist_test_payload(
7545 database: &BlockchainCacheDatabase,
7546 keys: Option<&PayloadKeySet>,
7547 intent: &ExecutionIntentRow,
7548 tx_hash: B256,
7549 raw_transaction: &[u8],
7550 ) {
7551 if let Some(keys) = keys {
7552 database.reserve_execution_payload_seal(keys).await.unwrap();
7553 let transaction_hash = tx_hash.to_string();
7554 let context =
7555 payload_context_identity(intent, &transaction_hash, 42_161, keys.deployment_id())
7556 .unwrap();
7557 let envelope = keys.seal(raw_transaction, &context).unwrap();
7558 database
7559 .add_execution_transaction_envelope(intent.id, 42_161, &transaction_hash, &envelope)
7560 .await
7561 .unwrap();
7562 } else {
7563 database
7564 .add_execution_transaction_hash(
7565 intent.id,
7566 42_161,
7567 &tx_hash.to_string(),
7568 raw_transaction,
7569 )
7570 .await
7571 .unwrap();
7572 }
7573 }
7574
7575 async fn later_reconnect(
7576 previous: BlockchainExecutionClient,
7577 http_rpc_url: String,
7578 ) -> anyhow::Error {
7579 let database = previous.cache.database.as_ref().unwrap().clone();
7580 let payload_keys = previous.payload_keys.clone();
7581 drop(previous);
7582 let mut next = test_client(http_rpc_url);
7583 next.cache.database = Some(database);
7584 next.payload_keys = payload_keys;
7585 next.signer = Some(Arc::new(
7586 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7587 ));
7588 next.reconcile_unresolved_execution().await.unwrap_err()
7589 }
7590
7591 fn start_with_events(
7592 client: &mut BlockchainExecutionClient,
7593 ) -> tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent> {
7594 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
7595 replace_exec_event_sender(sender);
7596 client.start().unwrap();
7597 receiver
7598 }
7599
7600 async fn await_pending_tasks(client: &BlockchainExecutionClient) {
7601 tokio::time::timeout(TEST_TIMEOUT, async {
7602 while !client.pending_tasks.all_finished() {
7603 tokio::time::sleep(Duration::from_millis(1)).await;
7604 }
7605 })
7606 .await
7607 .unwrap();
7608 }
7609
7610 fn collect_order_events(
7611 receiver: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
7612 ) -> Vec<OrderEventAny> {
7613 let mut events = Vec::new();
7614
7615 while let Ok(event) = receiver.try_recv() {
7616 if let ExecutionEvent::Order(order_event) = event {
7617 events.push(order_event);
7618 }
7619 }
7620 events
7621 }
7622
7623 fn assert_swap_quarantined_without_terminal_event(events: &[OrderEventAny]) {
7624 assert_eq!(events.len(), 1, "was: {events:?}");
7625 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
7626 }
7627
7628 fn assert_swap_submitted_and_filled(events: &[OrderEventAny]) {
7629 assert_eq!(events.len(), 2, "was: {events:?}");
7630 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
7631 assert!(matches!(&events[1], OrderEventAny::Filled(_)));
7632 }
7633
7634 fn expected_swap_calldata(min_amount_out: U256) -> Vec<u8> {
7635 UniswapV3SwapRouter::exactInputSingleCall {
7636 params: UniswapV3SwapRouter::ExactInputSingleParams {
7637 tokenIn: WETH_ADDRESS,
7638 tokenOut: address!("af88d065e77c8cC2239327C5EDb3A432268e5831"),
7639 fee: U24::try_from(500u32).unwrap(),
7640 recipient: address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
7641 deadline: U256::from(FIXTURE_BLOCK_TIMESTAMP + 300),
7642 amountIn: U256::from(1_000_000_000_000_000u64),
7643 amountOutMinimum: min_amount_out,
7644 sqrtPriceLimitX96: U160::ZERO,
7645 },
7646 }
7647 .abi_encode()
7648 }
7649
7650 fn expected_min_amount_out(slippage_bps: u32) -> U256 {
7652 expected_min_amount_out_for(&test_pool(), true, slippage_bps)
7653 }
7654
7655 fn expected_min_amount_out_for(pool: &Pool, zero_for_one: bool, slippage_bps: u32) -> U256 {
7656 let profiler = test_profiler(pool, FIXTURE_BLOCK);
7657 let quote = profiler
7658 .swap_exact_in(U256::from(1_000_000_000_000_000u64), zero_for_one, None)
7659 .unwrap();
7660 let quoted = exact_output_amount("e, zero_for_one).unwrap();
7661 derive_min_amount_out(quoted, slippage_bps).unwrap()
7662 }
7663
7664 fn expected_sell_quote_amount() -> U256 {
7665 let profiler = test_profiler(&test_pool(), FIXTURE_BLOCK);
7666 let quote = profiler
7667 .swap_exact_in(U256::from(1_000_000_000_000_000u64), true, None)
7668 .unwrap();
7669 exact_output_amount("e, true).unwrap()
7670 }
7671
7672 fn quote_response(amount: U256) -> String {
7673 let result = (amount, U160::from(1u128 << 96), 0u32, U256::from(50_000u64)).abi_encode();
7674 serde_json::json!({
7675 "jsonrpc": "2.0",
7676 "id": 1,
7677 "result": hex::encode_prefixed(result),
7678 })
7679 .to_string()
7680 }
7681
7682 async fn expected_swap_tx(min_amount_out: U256) -> (B256, String) {
7683 let expected_tx = build_eip1559_transaction(
7686 42161,
7687 7,
7688 78_000,
7689 130_000_000,
7690 10_000_000,
7691 ROUTER_ADDRESS,
7692 U256::ZERO,
7693 Bytes::from(expected_swap_calldata(min_amount_out)),
7694 );
7695 sign_eip1559_transaction(
7696 expected_tx,
7697 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7698 )
7699 .await
7700 .map(|(hash, raw)| (hash, nautilus_core::hex::encode_prefixed(&raw)))
7701 .unwrap()
7702 }
7703
7704 fn expected_buy_base_amount() -> U256 {
7705 U256::from(1_000_000_000_000_000u64)
7706 }
7707
7708 fn expected_buy_amount_in() -> U256 {
7709 let profiler = test_profiler(&test_pool(), FIXTURE_BLOCK);
7710 profiler
7711 .swap_exact_out(expected_buy_base_amount(), false, None)
7712 .unwrap()
7713 .get_input_amount()
7714 }
7715
7716 fn expected_buy_min_amount_out(slippage_bps: u32) -> U256 {
7717 derive_min_amount_out(expected_buy_base_amount(), slippage_bps).unwrap()
7718 }
7719
7720 fn expected_buy_swap_calldata(min_amount_out: U256, amount_in: U256) -> Vec<u8> {
7721 UniswapV3SwapRouter::exactInputSingleCall {
7722 params: UniswapV3SwapRouter::ExactInputSingleParams {
7723 tokenIn: USDC_ADDRESS,
7724 tokenOut: WETH_ADDRESS,
7725 fee: U24::try_from(500u32).unwrap(),
7726 recipient: address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
7727 deadline: U256::from(FIXTURE_BLOCK_TIMESTAMP + 300),
7728 amountIn: amount_in,
7729 amountOutMinimum: min_amount_out,
7730 sqrtPriceLimitX96: U160::ZERO,
7731 },
7732 }
7733 .abi_encode()
7734 }
7735
7736 async fn expected_buy_swap_tx(min_amount_out: U256, amount_in: U256) -> (B256, String) {
7737 let expected_tx = build_eip1559_transaction(
7738 42161,
7739 7,
7740 78_000,
7741 130_000_000,
7742 10_000_000,
7743 ROUTER_ADDRESS,
7744 U256::ZERO,
7745 Bytes::from(expected_buy_swap_calldata(min_amount_out, amount_in)),
7746 );
7747 sign_eip1559_transaction(
7748 expected_tx,
7749 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7750 )
7751 .await
7752 .map(|(hash, raw)| (hash, nautilus_core::hex::encode_prefixed(&raw)))
7753 .unwrap()
7754 }
7755
7756 fn finalized_buy_swap_receipt(tx_hash: B256, amount_in: U256) -> String {
7757 finalized_buy_swap_receipt_with_base_out(tx_hash, amount_in, expected_buy_base_amount())
7758 }
7759
7760 fn finalized_buy_swap_receipt_with_base_out(
7761 tx_hash: B256,
7762 amount_in: U256,
7763 base_out: U256,
7764 ) -> String {
7765 let data = (
7766 -I256::try_from(u128::try_from(base_out).unwrap()).unwrap(),
7767 I256::try_from(u128::try_from(amount_in).unwrap()).unwrap(),
7768 U160::from(1_u128 << 96),
7769 TEST_LIQUIDITY,
7770 I24::try_from(0).unwrap(),
7771 )
7772 .abi_encode();
7773 serde_json::json!({
7774 "jsonrpc": "2.0",
7775 "id": 1,
7776 "result": {
7777 "transactionHash": tx_hash.to_string(),
7778 "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7779 "blockNumber": "0x1cf0d41",
7780 "transactionIndex": "0x2",
7781 "gasUsed": "0xc3c0",
7782 "effectiveGasPrice": "0x5f5e100",
7783 "status": "0x1",
7784 "logs": [{
7785 "removed": false,
7786 "logIndex": "0x6",
7787 "transactionIndex": "0x2",
7788 "transactionHash": tx_hash.to_string(),
7789 "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7790 "blockNumber": "0x1cf0d41",
7791 "address": test_pool().address.to_string(),
7792 "data": hex::encode_prefixed(data),
7793 "topics": [
7794 "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67",
7795 "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266",
7796 "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266"
7797 ]
7798 }]
7799 }
7800 })
7801 .to_string()
7802 }
7803
7804 fn finalized_buy_swap_block(tx_hash: B256, min_amount_out: U256, amount_in: U256) -> String {
7805 serde_json::json!({
7806 "jsonrpc": "2.0",
7807 "id": 1,
7808 "result": {
7809 "number": "0x1cf0d41",
7810 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7811 "parentHash": FIXTURE_BLOCK_HASH,
7812 "timestamp": "0x69044a21",
7813 "baseFeePerGas": "0x5f5e100",
7814 "transactions": [{
7815 "hash": tx_hash.to_string(),
7816 "from": WALLET,
7817 "nonce": "0x7",
7818 "chainId": "0xa4b1",
7819 "type": "0x2",
7820 "to": ROUTER,
7821 "input": hex::encode_prefixed(expected_buy_swap_calldata(min_amount_out, amount_in)),
7822 "value": "0x0",
7823 "gas": "0x130b0",
7824 "maxFeePerGas": "0x7bfa480",
7825 "maxPriorityFeePerGas": "0x989680"
7826 }]
7827 }
7828 })
7829 .to_string()
7830 }
7831
7832 fn finalized_buy_swap_rpc_state(
7833 tx_hash: B256,
7834 min_amount_out: U256,
7835 amount_in: U256,
7836 ) -> MockRpcState {
7837 let receipt = finalized_buy_swap_receipt(tx_hash, amount_in);
7838 let block = finalized_buy_swap_block(tx_hash, min_amount_out, amount_in);
7839 with_finalized_identity(
7840 signing_rpc_state()
7841 .with_response("eth_getTransactionReceipt", &receipt)
7842 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
7843 .with_send_raw_transaction_echo()
7844 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE)
7845 .with_call_response(ALLOWANCE_SELECTOR, CALL_ALLOWANCE),
7846 &block,
7847 &receipt,
7848 )
7849 }
7850
7851 fn finalized_swap_receipt(tx_hash: B256) -> String {
7852 let data = (
7853 I256::try_from(1_000_000_000_000_000_i128).unwrap(),
7854 I256::try_from(-1_000_000_i128).unwrap(),
7855 U160::from(1_u128 << 96),
7856 TEST_LIQUIDITY,
7857 I24::try_from(0).unwrap(),
7858 )
7859 .abi_encode();
7860 serde_json::json!({
7861 "jsonrpc": "2.0",
7862 "id": 1,
7863 "result": {
7864 "transactionHash": tx_hash.to_string(),
7865 "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7866 "blockNumber": "0x1cf0d41",
7867 "transactionIndex": "0x2",
7868 "gasUsed": "0xc3c0",
7869 "effectiveGasPrice": "0x5f5e100",
7870 "status": "0x1",
7871 "logs": [{
7872 "removed": false,
7873 "logIndex": "0x6",
7874 "transactionIndex": "0x2",
7875 "transactionHash": tx_hash.to_string(),
7876 "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7877 "blockNumber": "0x1cf0d41",
7878 "address": test_pool().address.to_string(),
7879 "data": hex::encode_prefixed(data),
7880 "topics": [
7881 "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67",
7882 "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266",
7883 "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266"
7884 ]
7885 }]
7886 }
7887 })
7888 .to_string()
7889 }
7890
7891 fn finalized_swap_receipt_with_unrelated_swap(tx_hash: B256) -> String {
7892 let mut receipt: serde_json::Value =
7893 serde_json::from_str(&finalized_swap_receipt(tx_hash)).unwrap();
7894 let logs = receipt["result"]["logs"].as_array_mut().unwrap();
7895 let mut unrelated = logs[0].clone();
7896 unrelated["address"] = serde_json::json!(ROUTER);
7897 unrelated["logIndex"] = serde_json::json!("0x7");
7898 logs.push(unrelated);
7899 receipt.to_string()
7900 }
7901
7902 fn finalized_swap_block(tx_hash: B256, min_amount_out: U256) -> String {
7903 serde_json::json!({
7904 "jsonrpc": "2.0",
7905 "id": 1,
7906 "result": {
7907 "number": "0x1cf0d41",
7908 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7909 "parentHash": FIXTURE_BLOCK_HASH,
7910 "timestamp": "0x69044a21",
7911 "baseFeePerGas": "0x5f5e100",
7912 "transactions": [{
7913 "hash": tx_hash.to_string(),
7914 "from": WALLET,
7915 "nonce": "0x7",
7916 "chainId": "0xa4b1",
7917 "type": "0x2",
7918 "to": ROUTER,
7919 "input": hex::encode_prefixed(expected_swap_calldata(min_amount_out)),
7920 "value": "0x0",
7921 "gas": "0x130b0",
7922 "maxFeePerGas": "0x7bfa480",
7923 "maxPriorityFeePerGas": "0x989680"
7924 }]
7925 }
7926 })
7927 .to_string()
7928 }
7929
7930 fn finalized_swap_rpc_state(tx_hash: B256, min_amount_out: U256) -> MockRpcState {
7931 let receipt = finalized_swap_receipt(tx_hash);
7932 let block = finalized_swap_block(tx_hash, min_amount_out);
7933 with_finalized_identity(
7934 signing_rpc_state()
7935 .with_response("eth_getTransactionReceipt", &receipt)
7936 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
7937 .with_send_raw_transaction_echo()
7938 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE)
7939 .with_call_response(ALLOWANCE_SELECTOR, CALL_ALLOWANCE),
7940 &block,
7941 &receipt,
7942 )
7943 }
7944
7945 fn with_finalized_identity(
7946 state: MockRpcState,
7947 block_response: &str,
7948 receipt_response: &str,
7949 ) -> MockRpcState {
7950 let block: serde_json::Value = serde_json::from_str(block_response).unwrap();
7951 let receipt: serde_json::Value = serde_json::from_str(receipt_response).unwrap();
7952 let transaction = block["result"]["transactions"][0].clone();
7953 let transaction_response = serde_json::json!({
7954 "jsonrpc": "2.0",
7955 "id": 1,
7956 "result": transaction,
7957 })
7958 .to_string();
7959 let success = receipt["result"]["status"] == "0x1";
7960 let mut trace = serde_json::json!({
7961 "type": "CALL",
7962 "from": transaction["from"],
7963 "to": transaction["to"],
7964 "value": transaction["value"],
7965 "gas": transaction["gas"],
7966 "gasUsed": receipt["result"]["gasUsed"],
7967 "input": transaction["input"],
7968 "output": "0x",
7969 "calls": [],
7970 });
7971
7972 if !success {
7973 trace["error"] = serde_json::json!("execution reverted");
7974 }
7975 let trace_response = serde_json::json!({
7976 "jsonrpc": "2.0",
7977 "id": 1,
7978 "result": trace,
7979 })
7980 .to_string();
7981 state
7982 .with_response("eth_getTransactionByHash", &transaction_response)
7983 .with_response("debug_traceTransaction", &trace_response)
7984 }
7985
7986 fn receipt_with_transaction_hash(receipt_response: &str, tx_hash: B256) -> String {
7987 let mut receipt: serde_json::Value = serde_json::from_str(receipt_response).unwrap();
7988 receipt["result"]["transactionHash"] = serde_json::json!(tx_hash.to_string());
7989 receipt.to_string()
7990 }
7991
7992 fn replacement_head_block(tx_hash: B256) -> String {
7993 serde_json::json!({
7994 "jsonrpc": "2.0",
7995 "id": 1,
7996 "result": {
7997 "number": "0x1cf0d40",
7998 "hash": "0x1111111111111111111111111111111111111111111111111111111111111111",
7999 "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000001",
8000 "timestamp": "0x69044a20",
8001 "baseFeePerGas": "0x5f5e100",
8002 "transactions": [{
8003 "hash": "0x3333333333333333333333333333333333333333333333333333333333333333",
8004 "from": "0x0000000000000000000000000000000000000001",
8005 "nonce": "0x1",
8006 "type": "0x0",
8007 "to": WETH,
8008 "input": "0x",
8009 "value": "0x0",
8010 "gas": "0x5208"
8011 }, {
8012 "hash": tx_hash.to_string(),
8013 "from": WALLET,
8014 "nonce": "0x7",
8015 "chainId": "0xa4b1",
8016 "type": "0x2",
8017 "to": WETH,
8018 "input": "0xd0e30db0",
8019 "value": "0x38d7ea4c68000",
8020 "gas": "0x130b0",
8021 "maxFeePerGas": "0x7bfa480",
8022 "maxPriorityFeePerGas": "0x989680"
8023 }]
8024 }
8025 })
8026 .to_string()
8027 }
8028
8029 fn finalized_wrap_block(tx_hash: B256) -> String {
8032 serde_json::json!({
8033 "jsonrpc": "2.0",
8034 "id": 1,
8035 "result": {
8036 "number": "0x1cf0d41",
8037 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
8038 "parentHash": FIXTURE_BLOCK_HASH,
8039 "timestamp": "0x69044a21",
8040 "baseFeePerGas": "0x5f5e100",
8041 "transactions": [{
8042 "hash": tx_hash.to_string(),
8043 "from": WALLET,
8044 "nonce": "0x7",
8045 "chainId": "0xa4b1",
8046 "type": "0x2",
8047 "to": WETH,
8048 "input": "0xd0e30db0",
8049 "value": "0x38d7ea4c68000",
8050 "gas": "0x130b0",
8051 "maxFeePerGas": "0x7bfa480",
8052 "maxPriorityFeePerGas": "0x989680"
8053 }]
8054 }
8055 })
8056 .to_string()
8057 }
8058
8059 fn finalized_approve_block(tx_hash: B256, amount: U256) -> String {
8062 let calldata = ERC20::approveCall {
8063 spender: ROUTER_ADDRESS,
8064 amount,
8065 }
8066 .abi_encode();
8067 serde_json::json!({
8068 "jsonrpc": "2.0",
8069 "id": 1,
8070 "result": {
8071 "number": "0x1cf0d41",
8072 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
8073 "parentHash": FIXTURE_BLOCK_HASH,
8074 "timestamp": "0x69044a21",
8075 "baseFeePerGas": "0x5f5e100",
8076 "transactions": [{
8077 "hash": tx_hash.to_string(),
8078 "from": WALLET,
8079 "nonce": "0x7",
8080 "chainId": "0xa4b1",
8081 "type": "0x2",
8082 "to": WETH,
8083 "input": hex::encode_prefixed(calldata),
8084 "value": "0x0",
8085 "gas": "0x130b0",
8086 "maxFeePerGas": "0x7bfa480",
8087 "maxPriorityFeePerGas": "0x989680"
8088 }]
8089 }
8090 })
8091 .to_string()
8092 }
8093
8094 fn fixture_sell_plan() -> SwapPlan {
8095 let pool = test_pool();
8096 let order = test_market_sell_order(pool.instrument_id);
8097 let quote_token = pool.get_quote_token();
8098 SwapPlan {
8099 order,
8100 quote_currency: Currency::new_checked(
8101 "e_token.symbol,
8102 quote_token.decimals,
8103 0,
8104 "e_token.name,
8105 CurrencyType::Crypto,
8106 )
8107 .unwrap(),
8108 instrument_id: pool.instrument_id,
8109 pool_address: pool.address,
8110 router: ROUTER_ADDRESS,
8111 factory: UNISWAP_V3.dex.factory,
8112 weth: WETH_ADDRESS,
8113 token_in: WETH_ADDRESS,
8114 token_out: USDC_ADDRESS,
8115 fee: U24::try_from(500u32).unwrap(),
8116 amount_in: U256::from(1_000_000_000_000_000u64),
8117 min_amount_out: expected_min_amount_out(50),
8118 slippage_bps: 50,
8119 quote_spend_ceiling: None,
8120 profiler_position: Some(profiler_event_position()),
8121 pool,
8122 }
8123 }
8124
8125 fn fixture_block_response(number: u64, hash: B256) -> String {
8126 let parent_hash = if number == FIXTURE_BLOCK {
8127 B256::from_str("0x0000000000000000000000000000000000000000000000000000000000000001")
8128 .unwrap()
8129 } else {
8130 B256::from_str(FIXTURE_BLOCK_HASH).unwrap()
8131 };
8132 serde_json::json!({
8133 "jsonrpc": "2.0",
8134 "id": 1,
8135 "result": {
8136 "number": format!("0x{number:x}"),
8137 "hash": hash.to_string(),
8138 "parentHash": parent_hash.to_string(),
8139 "timestamp": format!("0x{:x}", FIXTURE_BLOCK_TIMESTAMP + number - FIXTURE_BLOCK),
8140 "baseFeePerGas": "0x5f5e100",
8141 "transactions": []
8142 }
8143 })
8144 .to_string()
8145 }
8146
8147 fn profiler_event_receipt(pool_address: Address) -> String {
8148 let transaction_hash = B256::from([0x33; 32]);
8149 serde_json::json!({
8150 "jsonrpc": "2.0",
8151 "id": 1,
8152 "result": {
8153 "transactionHash": transaction_hash.to_string(),
8154 "blockHash": FIXTURE_BLOCK_HASH,
8155 "blockNumber": "0x1cf0d40",
8156 "transactionIndex": "0x2",
8157 "gasUsed": "0xc3c0",
8158 "effectiveGasPrice": "0x5f5e100",
8159 "status": "0x1",
8160 "logs": [{
8161 "removed": false,
8162 "logIndex": "0x6",
8163 "transactionIndex": "0x2",
8164 "transactionHash": transaction_hash.to_string(),
8165 "blockHash": FIXTURE_BLOCK_HASH,
8166 "blockNumber": "0x1cf0d40",
8167 "address": pool_address.to_string(),
8168 "data": "0x",
8169 "topics": [test_pool().dex.swap_created_event.as_ref()]
8170 }]
8171 }
8172 })
8173 .to_string()
8174 }
8175
8176 fn profiler_event_position() -> BlockPosition {
8177 BlockPosition::new(FIXTURE_BLOCK, B256::from([0x33; 32]).to_string(), 2, 6)
8178 .with_block_hash(Some(FIXTURE_BLOCK_HASH.to_string()))
8179 }
8180
8181 #[rstest]
8182 fn quantity_to_raw_amount_scales_by_token_decimals() {
8183 assert_eq!(
8184 quantity_to_raw_amount(Quantity::from("0.001"), 18).unwrap(),
8185 U256::from(1_000_000_000_000_000u64)
8186 );
8187 assert_eq!(
8188 quantity_to_raw_amount(Quantity::from("1.5"), 18).unwrap(),
8189 U256::from(1_500_000_000_000_000_000u128)
8190 );
8191 assert_eq!(
8192 quantity_to_raw_amount(Quantity::from("12.5"), 6).unwrap(),
8193 U256::from(12_500_000u64)
8194 );
8195 }
8196
8197 #[rstest]
8198 fn quantity_to_raw_amount_uses_defi_quantity_precision() {
8199 let amount = U256::from(10_000_000_000_000_000u64);
8200 let quantity = Quantity::from_u256(amount, 18).unwrap();
8201
8202 assert_eq!(quantity_to_raw_amount(quantity, 18).unwrap(), amount);
8203 }
8204
8205 #[rstest]
8206 fn quantity_to_raw_amount_rejects_zero() {
8207 let error = quantity_to_raw_amount(Quantity::from("0.0"), 18).unwrap_err();
8208
8209 assert_eq!(error.to_string(), "Order quantity must be positive");
8210 }
8211
8212 #[rstest]
8213 fn quantity_to_raw_amount_rejects_inexact_token_units() {
8214 let error = quantity_to_raw_amount(Quantity::from("0.0000001"), 6).unwrap_err();
8215
8216 assert!(
8217 error
8218 .to_string()
8219 .contains("is not exactly representable in 6 base token decimals"),
8220 "was: {error}"
8221 );
8222 }
8223
8224 #[rstest]
8225 fn raw_amount_to_quantity_inverts_base_quantity() {
8226 let quantity = Quantity::from("0.001");
8227 let amount = quantity_to_raw_amount(quantity, 18).unwrap();
8228
8229 assert_eq!(raw_amount_to_quantity(amount, 18).unwrap(), quantity);
8230 }
8231
8232 #[rstest]
8233 fn raw_amount_to_quantity_rejects_positive_amount_truncated_to_zero() {
8234 let error = raw_amount_to_quantity(U256::from(99u64), 18).unwrap_err();
8235
8236 assert!(
8237 error
8238 .to_string()
8239 .contains("is below representable quantity precision"),
8240 "was: {error}"
8241 );
8242 }
8243
8244 #[rstest]
8245 fn fill_price_from_quote_recovers_spent_quote_after_quantity_truncation() {
8246 let last_qty = raw_amount_to_quantity(U256::from(1_000_000_403_079_044u64), 18).unwrap();
8247 let quote_amount = U256::from(1_891_348u64);
8248 let quote = Currency::new_checked("USDC", 6, 0, "USD Coin", CurrencyType::Crypto).unwrap();
8249 let last_px = fill_price_from_quote(last_qty, quote_amount, quote).unwrap();
8250
8251 assert_eq!(
8252 Money::from_decimal(last_qty.as_decimal() * last_px.as_decimal(), quote).unwrap(),
8253 Money::from_u256(quote_amount, quote).unwrap()
8254 );
8255 }
8256
8257 #[rstest]
8258 fn swap_token_pair_is_directional() {
8259 assert_eq!(
8260 swap_token_pair(OrderSide::Sell, WETH_ADDRESS, USDC_ADDRESS).unwrap(),
8261 (WETH_ADDRESS, USDC_ADDRESS)
8262 );
8263 assert_eq!(
8264 swap_token_pair(OrderSide::Buy, WETH_ADDRESS, USDC_ADDRESS).unwrap(),
8265 (USDC_ADDRESS, WETH_ADDRESS)
8266 );
8267 }
8268
8269 #[rstest]
8270 fn restore_swap_plan_buy_uses_quote_input() {
8271 let (client, cache) =
8272 swap_client_with_cache(buy_test_config("http://127.0.0.1:1".to_string()));
8273 let order = test_market_buy_order(test_pool().instrument_id);
8274 cache
8275 .borrow_mut()
8276 .add_order(order.clone(), None, None, true)
8277 .unwrap();
8278 let amount_in = U256::from(2_345_678u64);
8279 let intent = ExecutionIntentRow {
8280 id: 7,
8281 schema_version: crate::execution::transaction::EXECUTION_SCHEMA_VERSION,
8282 chain_id: 42161,
8283 wallet_address: WALLET.to_string(),
8284 nonce: Some(7),
8285 purpose: "swap".to_string(),
8286 status: "finalized".to_string(),
8287 client_order_id: Some(order.client_order_id().to_string()),
8288 trader_id: Some(order.trader_id().to_string()),
8289 strategy_id: Some(order.strategy_id().to_string()),
8290 account_id: Some("BLOCKCHAIN-001".to_string()),
8291 instrument_id: Some(order.instrument_id().to_string()),
8292 pool_address: Some(test_pool().address.to_string()),
8293 transaction_to: ROUTER.to_string(),
8294 transaction_input: "0x".to_string(),
8295 transaction_value: "0".to_string(),
8296 amount_in: Some(amount_in.to_string()),
8297 created_block: FIXTURE_BLOCK,
8298 acknowledgement_emitted: true,
8299 fill_emitted: false,
8300 terminal_emitted: false,
8301 active: true,
8302 };
8303
8304 let plan = client.restore_swap_plan(&intent).unwrap();
8305
8306 assert_eq!(plan.token_in, USDC_ADDRESS);
8307 assert_eq!(plan.token_out, WETH_ADDRESS);
8308 assert_eq!(plan.amount_in, amount_in);
8309 }
8310
8311 #[rstest]
8312 #[case(1_000_000, 50, 995_000)]
8313 #[case(1_000_000, 0, 1_000_000)]
8314 #[case(1_000_000, 200, 980_000)]
8315 #[case(10_000, 9_999, 1)]
8316 fn derive_min_amount_out_applies_slippage(
8317 #[case] quoted: u64,
8318 #[case] slippage_bps: u32,
8319 #[case] expected: u64,
8320 ) {
8321 assert_eq!(
8322 derive_min_amount_out(U256::from(quoted), slippage_bps).unwrap(),
8323 U256::from(expected)
8324 );
8325 }
8326
8327 #[rstest]
8328 fn derive_min_amount_out_rejects_zero_result() {
8329 let error = derive_min_amount_out(U256::from(9_999u64), 9_999).unwrap_err();
8330
8331 assert!(
8332 error.to_string().contains("Derived minimum output is zero"),
8333 "was: {error}"
8334 );
8335 }
8336
8337 #[rstest]
8338 fn derive_min_amount_out_rejects_full_slippage() {
8339 let error = derive_min_amount_out(U256::from(1_000_000u64), 10_000).unwrap_err();
8340
8341 assert!(
8342 error.to_string().contains("must be below 10000"),
8343 "was: {error}"
8344 );
8345 }
8346
8347 #[rstest]
8348 fn replacement_scan_range_is_bounded_and_checked() {
8349 assert_eq!(
8350 replacement_scan_range(10, 10).unwrap(),
8351 RangeInclusive::new(10, 10)
8352 );
8353 assert_eq!(
8354 replacement_scan_range(10, 10 + MAX_REPLACEMENT_SCAN_BLOCKS).unwrap(),
8355 RangeInclusive::new(10, 10 + MAX_REPLACEMENT_SCAN_BLOCKS - 1)
8356 );
8357 assert!(
8358 replacement_scan_range(11, 10)
8359 .unwrap_err()
8360 .to_string()
8361 .contains("is behind execution creation block")
8362 );
8363 assert_eq!(
8364 replacement_scan_range(u64::MAX - 1, u64::MAX).unwrap(),
8365 RangeInclusive::new(u64::MAX - 1, u64::MAX)
8366 );
8367 }
8368
8369 #[rstest]
8370 fn terminal_execution_event_ids_are_stable_and_kind_specific() {
8371 let transaction_hash = B256::from([0x42; 32]);
8372
8373 let fill = execution_event_id(transaction_hash, b"fill");
8374 let fill_retry = execution_event_id(transaction_hash, b"fill");
8375 let reverted = execution_event_id(transaction_hash, b"reverted");
8376
8377 assert_eq!(fill, fill_retry);
8378 assert_ne!(fill, reverted);
8379 }
8380
8381 #[rstest]
8382 fn prepared_transaction_debug_redacts_raw_transaction() {
8383 let prepared = PreparedTransaction {
8384 intent_id: 1,
8385 created_block: 2,
8386 nonce: 3,
8387 tx_hash: B256::ZERO,
8388 raw_tx: vec![0xde, 0xad, 0xbe, 0xef],
8389 payload_lease: None,
8390 };
8391
8392 let debug = format!("{prepared:?}");
8393
8394 assert!(debug.contains("raw_tx: \"<redacted>\""));
8395 assert!(!debug.contains("[222, 173, 190, 239]"));
8396 }
8397
8398 #[rstest]
8399 fn call_trace_rejects_unreviewed_internal_edge() {
8400 let signed = crate::execution::transaction::DecodedSignedTransaction {
8401 hash: B256::from([1; 32]),
8402 signer: Address::from_str(WALLET).unwrap(),
8403 chain_id: 42_161,
8404 nonce: 7,
8405 to: ROUTER_ADDRESS,
8406 value: U256::ZERO,
8407 input: Bytes::from(expected_swap_calldata(expected_min_amount_out(50))),
8408 gas_limit: 78_000,
8409 max_fee_per_gas: 130_000_000,
8410 max_priority_fee_per_gas: 10_000_000,
8411 };
8412 let input_digest = keccak256(&signed.input);
8413 let trace = VerifiedCallTrace {
8414 call_type: RpcCallType::Call,
8415 from: signed.signer,
8416 to: Some(signed.to),
8417 value: signed.value,
8418 input_selector: signed
8419 .input
8420 .get(..4)
8421 .map(|selector| selector.try_into().unwrap()),
8422 input_digest,
8423 success: true,
8424 calls: vec![VerifiedCallTrace {
8425 call_type: RpcCallType::Call,
8426 from: ROUTER_ADDRESS,
8427 to: Some(WETH_ADDRESS),
8428 value: U256::ZERO,
8429 input_selector: None,
8430 input_digest: B256::ZERO,
8431 success: true,
8432 calls: Vec::new(),
8433 }],
8434 };
8435 let manifest = &test_config("http://127.0.0.1:1".to_string())
8436 .verification
8437 .unwrap()
8438 .deployment_manifest;
8439
8440 let error = validate_call_trace(&trace, &signed, true, "swap_sell", manifest).unwrap_err();
8441
8442 assert!(
8443 error.to_string().contains("unreviewed call edge"),
8444 "was: {error}"
8445 );
8446 }
8447
8448 #[rstest]
8449 fn call_trace_rejects_unlisted_precompile_target() {
8450 let manifest = test_config("http://127.0.0.1:1".to_string())
8451 .verification
8452 .unwrap()
8453 .deployment_manifest;
8454 let calls = [VerifiedCallTrace {
8455 call_type: RpcCallType::Call,
8456 from: ROUTER_ADDRESS,
8457 to: Some(address!("0000000000000000000000000000000000000007")),
8458 value: U256::ZERO,
8459 input_selector: None,
8460 input_digest: B256::ZERO,
8461 success: true,
8462 calls: Vec::new(),
8463 }];
8464
8465 let error =
8466 validate_internal_calls(&calls, ROUTER_ADDRESS, "swap_sell", &manifest).unwrap_err();
8467
8468 assert_eq!(
8469 error.to_string(),
8470 format!(
8471 "Verified call trace contains an unreviewed call edge {ROUTER_ADDRESS} -> {} for swap_sell",
8472 address!("0000000000000000000000000000000000000007")
8473 )
8474 );
8475 }
8476
8477 #[rstest]
8478 fn call_trace_requires_exact_call_type() {
8479 let manifest = test_config("http://127.0.0.1:1".to_string())
8480 .verification
8481 .unwrap()
8482 .deployment_manifest;
8483 let calls = [VerifiedCallTrace {
8484 call_type: RpcCallType::Staticcall,
8485 from: ROUTER_ADDRESS,
8486 to: Some(test_pool().address),
8487 value: U256::ZERO,
8488 input_selector: None,
8489 input_digest: B256::ZERO,
8490 success: true,
8491 calls: Vec::new(),
8492 }];
8493
8494 let error =
8495 validate_internal_calls(&calls, ROUTER_ADDRESS, "swap_sell", &manifest).unwrap_err();
8496
8497 assert!(
8498 error.to_string().contains("unreviewed staticcall edge"),
8499 "was: {error}"
8500 );
8501 }
8502
8503 #[rstest]
8504 fn call_trace_requires_exact_caller() {
8505 let manifest = test_config("http://127.0.0.1:1".to_string())
8506 .verification
8507 .unwrap()
8508 .deployment_manifest;
8509 let calls = [VerifiedCallTrace {
8510 call_type: RpcCallType::Call,
8511 from: WETH_ADDRESS,
8512 to: Some(test_pool().address),
8513 value: U256::ZERO,
8514 input_selector: None,
8515 input_digest: B256::ZERO,
8516 success: true,
8517 calls: Vec::new(),
8518 }];
8519
8520 let error =
8521 validate_internal_calls(&calls, ROUTER_ADDRESS, "swap_sell", &manifest).unwrap_err();
8522
8523 assert_eq!(
8524 error.to_string(),
8525 "Verified call trace child has an invalid caller context"
8526 );
8527 }
8528
8529 #[rstest]
8530 fn call_trace_rejects_contract_creation() {
8531 let signed = crate::execution::transaction::DecodedSignedTransaction {
8532 hash: B256::from([1; 32]),
8533 signer: Address::from_str(WALLET).unwrap(),
8534 chain_id: 42_161,
8535 nonce: 7,
8536 to: ROUTER_ADDRESS,
8537 value: U256::ZERO,
8538 input: Bytes::from(expected_swap_calldata(expected_min_amount_out(50))),
8539 gas_limit: 78_000,
8540 max_fee_per_gas: 130_000_000,
8541 max_priority_fee_per_gas: 10_000_000,
8542 };
8543 let trace = VerifiedCallTrace {
8544 call_type: RpcCallType::Call,
8545 from: signed.signer,
8546 to: Some(signed.to),
8547 value: signed.value,
8548 input_selector: signed
8549 .input
8550 .get(..4)
8551 .map(|selector| selector.try_into().unwrap()),
8552 input_digest: keccak256(&signed.input),
8553 success: true,
8554 calls: vec![VerifiedCallTrace {
8555 call_type: RpcCallType::Create,
8556 from: ROUTER_ADDRESS,
8557 to: Some(address!("0000000000000000000000000000000000000007")),
8558 value: U256::ZERO,
8559 input_selector: None,
8560 input_digest: B256::ZERO,
8561 success: true,
8562 calls: Vec::new(),
8563 }],
8564 };
8565 let manifest = &test_config("http://127.0.0.1:1".to_string())
8566 .verification
8567 .unwrap()
8568 .deployment_manifest;
8569
8570 let error = validate_call_trace(&trace, &signed, true, "swap_sell", manifest).unwrap_err();
8571
8572 assert!(
8573 error
8574 .to_string()
8575 .contains("forbidden state-changing operation"),
8576 "was: {error}"
8577 );
8578 }
8579
8580 #[tokio::test]
8581 async fn live_arbitrum_numbered_swap_reads_are_available() {
8582 if std::env::var(LIVE_READ_SMOKE_ENV).as_deref() != Ok("1") {
8583 eprintln!("{LIVE_READ_SMOKE_ENV} is not 1; skipping live read smoke");
8584 return;
8585 }
8586
8587 let rpc_url = std::env::var("ARBITRUM_RPC_HTTP_URL")
8588 .unwrap_or_else(|_| LIVE_READ_SMOKE_RPC.to_string());
8589 let rpc = Arc::new(BlockchainHttpRpcClient::new(rpc_url, None, None));
8590 let anchor = rpc.latest_block().await.unwrap();
8591 let pool = test_pool();
8592 let factory = UNISWAP_V3.dex.factory;
8593 let wallet = Address::from_str(WALLET).unwrap();
8594 let mut balance_call =
8595 nautilus_core::hex::decode(BALANCE_OF_SELECTOR.trim_start_matches("0x")).unwrap();
8596 balance_call.extend_from_slice(&[0; 12]);
8597 balance_call.extend_from_slice(wallet.as_slice());
8598
8599 let code = rpc
8600 .get_code_at(&ROUTER_ADDRESS, anchor.number)
8601 .await
8602 .unwrap();
8603 let router_factory = rpc
8604 .call_at(
8605 None,
8606 &ROUTER_ADDRESS,
8607 U256::ZERO,
8608 &UniswapV3RouterState::factoryCall {}.abi_encode(),
8609 anchor.number,
8610 )
8611 .await
8612 .unwrap();
8613 let router_factory =
8614 UniswapV3RouterState::factoryCall::abi_decode_returns(&router_factory).unwrap();
8615 let router_weth = rpc
8616 .call_at(
8617 None,
8618 &ROUTER_ADDRESS,
8619 U256::ZERO,
8620 &UniswapV3RouterState::WETH9Call {}.abi_encode(),
8621 anchor.number,
8622 )
8623 .await
8624 .unwrap();
8625 let router_weth =
8626 UniswapV3RouterState::WETH9Call::abi_decode_returns(&router_weth).unwrap();
8627 let registered_pool_call = UniswapV3Factory::getPoolCall {
8628 tokenA: WETH_ADDRESS,
8629 tokenB: USDC_ADDRESS,
8630 fee: U24::try_from(500u32).unwrap(),
8631 }
8632 .abi_encode();
8633 let registered_pool = rpc
8634 .call_at(
8635 None,
8636 &factory,
8637 U256::ZERO,
8638 ®istered_pool_call,
8639 anchor.number,
8640 )
8641 .await
8642 .unwrap();
8643 let registered_pool =
8644 UniswapV3Factory::getPoolCall::abi_decode_returns(®istered_pool).unwrap();
8645 let weth_decimals = rpc
8646 .call_at(
8647 None,
8648 &WETH_ADDRESS,
8649 U256::ZERO,
8650 &ERC20::decimalsCall {}.abi_encode(),
8651 anchor.number,
8652 )
8653 .await
8654 .unwrap();
8655 let weth_decimals = ERC20::decimalsCall::abi_decode_returns(&weth_decimals).unwrap();
8656 let usdc_decimals = rpc
8657 .call_at(
8658 None,
8659 &USDC_ADDRESS,
8660 U256::ZERO,
8661 &ERC20::decimalsCall {}.abi_encode(),
8662 anchor.number,
8663 )
8664 .await
8665 .unwrap();
8666 let usdc_decimals = ERC20::decimalsCall::abi_decode_returns(&usdc_decimals).unwrap();
8667 let allowance_call = ERC20::allowanceCall {
8668 owner: wallet,
8669 spender: ROUTER_ADDRESS,
8670 }
8671 .abi_encode();
8672 rpc.call_at(
8673 None,
8674 &WETH_ADDRESS,
8675 U256::ZERO,
8676 &allowance_call,
8677 anchor.number,
8678 )
8679 .await
8680 .unwrap();
8681 let balance_call = ERC20::balanceOfCall { account: wallet }.abi_encode();
8682 rpc.call_at(
8683 None,
8684 &WETH_ADDRESS,
8685 U256::ZERO,
8686 &balance_call,
8687 anchor.number,
8688 )
8689 .await
8690 .unwrap();
8691 let gas = rpc
8692 .estimate_gas_at(
8693 &wallet,
8694 &WETH_ADDRESS,
8695 U256::ZERO,
8696 &balance_call,
8697 anchor.number,
8698 )
8699 .await
8700 .unwrap();
8701 rpc.get_balance_with_timeout(
8702 &wallet,
8703 Some(anchor.number),
8704 Some(EXECUTION_RPC_TIMEOUT_SECS),
8705 )
8706 .await
8707 .unwrap();
8708 let canonical = rpc.block_by_number(anchor.number, false).await.unwrap();
8709
8710 assert!(!code.is_empty());
8711 assert_eq!(router_factory, factory);
8712 assert_eq!(router_weth, WETH_ADDRESS);
8713 assert_eq!(registered_pool, pool.address);
8714 assert_eq!(weth_decimals, 18);
8715 assert_eq!(usdc_decimals, 6);
8716 assert!(gas > 0);
8717 assert_eq!(canonical.hash, anchor.hash);
8718 }
8719
8720 #[tokio::test]
8721 async fn swap_quote_rejects_missing_ingestion_block_hash() {
8722 let (client, state) = client_with_mock_rpc(execution_rpc_state()).await;
8723 let plan = fixture_sell_plan();
8724 let position = BlockPosition::new(
8725 FIXTURE_BLOCK,
8726 FIXTURE_BLOCK_HASH.to_string(),
8727 BLOCK_SCOPED_SNAPSHOT_INDEX,
8728 BLOCK_SCOPED_SNAPSHOT_INDEX,
8729 );
8730
8731 let error = validate_swap_quote(
8732 &position,
8733 &plan,
8734 100,
8735 &client.verification,
8736 &client
8737 .config
8738 .verification
8739 .as_ref()
8740 .unwrap()
8741 .deployment_manifest,
8742 )
8743 .await
8744 .unwrap_err();
8745
8746 assert!(
8747 error.to_string().contains("no ingestion-time block hash"),
8748 "was: {error}"
8749 );
8750 assert!(state.recorded_requests().is_empty());
8751 }
8752
8753 #[tokio::test]
8754 async fn swap_quote_rejects_replaced_ingestion_block() {
8755 let changed = fixture_block_response(FIXTURE_BLOCK, B256::from([0x44; 32]));
8756 let state = execution_rpc_state().with_parameter_response(
8757 "eth_getBlockByNumber",
8758 "0x1cf0d40",
8759 &changed,
8760 );
8761 let (client, _) = client_with_mock_rpc(state).await;
8762 let plan = fixture_sell_plan();
8763 let position = BlockPosition::new(
8764 FIXTURE_BLOCK,
8765 FIXTURE_BLOCK_HASH.to_string(),
8766 BLOCK_SCOPED_SNAPSHOT_INDEX,
8767 BLOCK_SCOPED_SNAPSHOT_INDEX,
8768 )
8769 .with_block_hash(Some(FIXTURE_BLOCK_HASH.to_string()));
8770
8771 let error = validate_swap_quote(
8772 &position,
8773 &plan,
8774 100,
8775 &client.verification,
8776 &client
8777 .config
8778 .verification
8779 .as_ref()
8780 .unwrap()
8781 .deployment_manifest,
8782 )
8783 .await
8784 .unwrap_err();
8785
8786 assert!(error.to_string().contains("changed from"), "was: {error}");
8787 }
8788
8789 #[tokio::test]
8790 async fn swap_quote_accepts_exact_canonical_event_watermark() {
8791 let pool = test_pool();
8792 let receipt = profiler_event_receipt(pool.address);
8793 let state = execution_rpc_state().with_response("eth_getTransactionReceipt", &receipt);
8794 let (client, _) = client_with_mock_rpc(state).await;
8795 let plan = fixture_sell_plan();
8796
8797 let anchors = validate_swap_quote(
8798 &profiler_event_position(),
8799 &plan,
8800 100,
8801 &client.verification,
8802 &client
8803 .config
8804 .verification
8805 .as_ref()
8806 .unwrap()
8807 .deployment_manifest,
8808 )
8809 .await
8810 .unwrap();
8811
8812 assert_eq!(anchors.watermark.number, profiler_event_position().number);
8813 assert_eq!(anchors.state.number, FIXTURE_BLOCK);
8814 assert_eq!(anchors.state.hash, B256::from([0x11; 32]));
8815 assert_eq!(anchors.state.timestamp, FIXTURE_BLOCK_TIMESTAMP);
8816 }
8817
8818 #[tokio::test]
8819 async fn swap_quote_rejects_mismatched_receipt_position() {
8820 let pool = test_pool();
8821 let mut receipt: serde_json::Value =
8822 serde_json::from_str(&profiler_event_receipt(pool.address)).unwrap();
8823 receipt["result"]["transactionIndex"] = serde_json::json!("0x3");
8824 let state =
8825 execution_rpc_state().with_response("eth_getTransactionReceipt", &receipt.to_string());
8826 let (client, _) = client_with_mock_rpc(state).await;
8827 let plan = fixture_sell_plan();
8828
8829 let error = validate_swap_quote(
8830 &profiler_event_position(),
8831 &plan,
8832 100,
8833 &client.verification,
8834 &client
8835 .config
8836 .verification
8837 .as_ref()
8838 .unwrap()
8839 .deployment_manifest,
8840 )
8841 .await
8842 .unwrap_err();
8843
8844 assert_eq!(
8845 error.to_string(),
8846 "Profiler receipt position does not match its ingestion watermark"
8847 );
8848 }
8849
8850 #[tokio::test]
8851 async fn swap_quote_rejects_watermark_from_different_pool() {
8852 let receipt = profiler_event_receipt(ROUTER_ADDRESS);
8853 let state = execution_rpc_state().with_response("eth_getTransactionReceipt", &receipt);
8854 let (client, _) = client_with_mock_rpc(state).await;
8855 let plan = fixture_sell_plan();
8856
8857 let error = validate_swap_quote(
8858 &profiler_event_position(),
8859 &plan,
8860 100,
8861 &client.verification,
8862 &client
8863 .config
8864 .verification
8865 .as_ref()
8866 .unwrap()
8867 .deployment_manifest,
8868 )
8869 .await
8870 .unwrap_err();
8871
8872 assert!(
8873 error
8874 .to_string()
8875 .contains("did not come from expected pool"),
8876 "was: {error}"
8877 );
8878 }
8879
8880 #[rstest]
8881 fn verified_sell_quote_sets_signed_amounts() {
8882 let plan = fixture_sell_plan();
8883 let quote = UniswapV3Quote {
8884 amount: expected_sell_quote_amount(),
8885 sqrt_price_x96_after: U160::from(1u128 << 96),
8886 initialized_ticks_crossed: 0,
8887 gas_estimate: U256::from(50_000u64),
8888 };
8889
8890 let (amount_in, min_amount_out) = verified_swap_amounts(&plan, quote).unwrap();
8891
8892 assert_eq!(amount_in, U256::from(1_000_000_000_000_000u64));
8893 assert_eq!(min_amount_out, expected_min_amount_out(50));
8894 }
8895
8896 #[rstest]
8897 fn exact_output_amount_extracts_negative_leg() {
8898 let pool = test_pool();
8899 let profiler = test_profiler(&pool, FIXTURE_BLOCK);
8900 let quote = profiler
8901 .swap_exact_in(U256::from(1_000_000_000_000_000u64), true, None)
8902 .unwrap();
8903
8904 let amount = exact_output_amount("e, true).unwrap();
8905
8906 assert!(amount < U256::from(1_000_000_000_000_000u64));
8907 assert!(amount > U256::from(990_000_000_000_000u64));
8908
8909 let error = exact_output_amount("e, false).unwrap_err();
8910 assert!(
8911 error.to_string().contains("is not a positive output"),
8912 "was: {error}"
8913 );
8914 }
8915
8916 #[rstest]
8917 #[case("0xC6962004f452bE9203591991D15f6b388e09E8D0.Arbitrum:UniswapV3", true)]
8918 #[case("0xC6962004f452bE9203591991D15f6b388e09E8D0.Ethereum:UniswapV3", false)]
8919 #[case("0xC6962004f452bE9203591991D15f6b388e09E8D0.Arbitrum:UniswapV4", false)]
8920 #[case("ETHUSDT-PERP.BINANCE", false)]
8921 fn handles_order_venue_matches_chain_and_dex(#[case] instrument: &str, #[case] expected: bool) {
8922 let client = test_client("http://127.0.0.1:1".to_string());
8923 let instrument_id: InstrumentId = instrument.parse().unwrap();
8924
8925 assert_eq!(client.handles_order_venue(instrument_id.venue), expected);
8926 }
8927
8928 #[tokio::test]
8929 async fn submit_order_denies_buy_without_allowlisted_pair() {
8930 let (mut client, cache) =
8931 swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
8932 let pool = test_pool();
8933 let order = test_market_buy_order(pool.instrument_id);
8934 cache
8935 .borrow_mut()
8936 .add_order(order.clone(), None, None, true)
8937 .unwrap();
8938 let mut receiver = start_with_events(&mut client);
8939
8940 client.submit_order(submit_order_cmd(&order)).unwrap();
8941
8942 let events = collect_order_events(&mut receiver);
8943 assert_eq!(events.len(), 1);
8944 let OrderEventAny::Denied(denied) = &events[0] else {
8945 panic!("expected OrderDenied, was {:?}", events[0]);
8946 };
8947 assert!(
8948 denied
8949 .reason
8950 .as_str()
8951 .contains("not in the `allowed_token_pairs` allowlist"),
8952 "was: {}",
8953 denied.reason
8954 );
8955 assert!(
8956 denied.reason.as_str().contains(&USDC_ADDRESS.to_string()),
8957 "was: {}",
8958 denied.reason
8959 );
8960 }
8961
8962 #[tokio::test]
8963 async fn submit_order_denies_buy_quote_denominated_quantity() {
8964 let (mut client, cache) =
8965 swap_client_with_cache(buy_test_config("http://127.0.0.1:1".to_string()));
8966 let pool = test_pool();
8967 let order = OrderTestBuilder::new(OrderType::Market)
8968 .trader_id(TraderId::from("TRADER-001"))
8969 .strategy_id(StrategyId::from("S-001"))
8970 .instrument_id(pool.instrument_id)
8971 .client_order_id(ClientOrderId::from("O-SWAP-BUY-001"))
8972 .side(OrderSide::Buy)
8973 .quantity(Quantity::from("0.001"))
8974 .quote_quantity(true)
8975 .build();
8976 cache
8977 .borrow_mut()
8978 .add_order(order.clone(), None, None, true)
8979 .unwrap();
8980 let mut receiver = start_with_events(&mut client);
8981
8982 client.submit_order(submit_order_cmd(&order)).unwrap();
8983
8984 let events = collect_order_events(&mut receiver);
8985 assert_eq!(events.len(), 1);
8986 let OrderEventAny::Denied(denied) = &events[0] else {
8987 panic!("expected OrderDenied, was {:?}", events[0]);
8988 };
8989 assert!(
8990 denied.reason.as_str().contains("Quote-denominated"),
8991 "was: {}",
8992 denied.reason
8993 );
8994 }
8995
8996 #[tokio::test]
8997 async fn submit_order_denies_buy_amount_above_max_order_amount() {
8998 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
8999 config.max_order_amount = Some(999_999_999_999_999);
9000 let (mut client, cache) = swap_client_with_cache(config);
9001 let order = test_market_buy_order(test_pool().instrument_id);
9002 cache
9003 .borrow_mut()
9004 .add_order(order.clone(), None, None, true)
9005 .unwrap();
9006 let mut receiver = start_with_events(&mut client);
9007
9008 client.submit_order(submit_order_cmd(&order)).unwrap();
9009
9010 let events = collect_order_events(&mut receiver);
9011 assert_eq!(events.len(), 1);
9012 let OrderEventAny::Denied(denied) = &events[0] else {
9013 panic!("expected OrderDenied, was {:?}", events[0]);
9014 };
9015 assert!(
9016 denied
9017 .reason
9018 .as_str()
9019 .contains("exceeds the configured `max_order_amount`"),
9020 "was: {}",
9021 denied.reason
9022 );
9023 }
9024
9025 #[tokio::test]
9026 async fn submit_order_denies_buy_without_quote_spend_limit() {
9027 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
9028 config.quote_spend_limits = None;
9029 let (mut client, cache) = swap_client_with_cache(config);
9030 let order = test_market_buy_order(test_pool().instrument_id);
9031 cache
9032 .borrow_mut()
9033 .add_order(order.clone(), None, None, true)
9034 .unwrap();
9035 let mut receiver = start_with_events(&mut client);
9036
9037 client.submit_order(submit_order_cmd(&order)).unwrap();
9038
9039 let events = collect_order_events(&mut receiver);
9040 assert_eq!(events.len(), 1);
9041 let OrderEventAny::Denied(denied) = &events[0] else {
9042 panic!("expected OrderDenied, was {:?}", events[0]);
9043 };
9044 assert!(
9045 denied
9046 .reason
9047 .as_str()
9048 .contains("No `quote_spend_limits` entry for BUY token pair"),
9049 "was: {}",
9050 denied.reason
9051 );
9052 }
9053
9054 #[tokio::test]
9055 async fn submit_order_uses_pair_specific_quote_spend_limit() {
9056 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
9057 config.quote_spend_limits = Some(vec![quote_spend_limit(
9058 WETH,
9059 USDC,
9060 18,
9061 "1000000000000000000",
9062 )]);
9063 let (mut client, cache) = swap_client_with_cache(config);
9064 let order = test_market_buy_order(test_pool().instrument_id);
9065 cache
9066 .borrow_mut()
9067 .add_order(order.clone(), None, None, true)
9068 .unwrap();
9069 let mut receiver = start_with_events(&mut client);
9070
9071 client.submit_order(submit_order_cmd(&order)).unwrap();
9072
9073 let events = collect_order_events(&mut receiver);
9074 assert_eq!(events.len(), 1);
9075 let OrderEventAny::Denied(denied) = &events[0] else {
9076 panic!("expected OrderDenied, was {:?}", events[0]);
9077 };
9078 assert!(
9079 denied
9080 .reason
9081 .as_str()
9082 .contains("No `quote_spend_limits` entry for BUY token pair"),
9083 "was: {}",
9084 denied.reason
9085 );
9086 }
9087
9088 #[tokio::test]
9089 async fn submit_order_denies_buy_with_quote_spend_precision_mismatch() {
9090 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
9091 config.quote_spend_limits.as_mut().unwrap()[0].spend_token_decimals = 18;
9092 let error = test_client_result(config, test_pool()).unwrap_err();
9093 assert!(
9094 error
9095 .to_string()
9096 .contains("Quote spend limit decimals do not match the deployment manifest"),
9097 "was: {error}"
9098 );
9099 }
9100
9101 #[tokio::test]
9102 async fn submit_order_denies_buy_with_zero_quote_spend_limit() {
9103 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
9104 config.quote_spend_limits.as_mut().unwrap()[0].max_amount = "0".to_string();
9105 let (mut client, cache) = swap_client_with_cache(config);
9106 let order = test_market_buy_order(test_pool().instrument_id);
9107 cache
9108 .borrow_mut()
9109 .add_order(order.clone(), None, None, true)
9110 .unwrap();
9111 let mut receiver = start_with_events(&mut client);
9112
9113 client.submit_order(submit_order_cmd(&order)).unwrap();
9114
9115 let events = collect_order_events(&mut receiver);
9116 assert_eq!(events.len(), 1);
9117 let OrderEventAny::Denied(denied) = &events[0] else {
9118 panic!("expected OrderDenied, was {:?}", events[0]);
9119 };
9120 assert!(
9121 denied
9122 .reason
9123 .as_str()
9124 .contains("exceeds the configured `quote_spend_limits` maximum 0"),
9125 "was: {}",
9126 denied.reason
9127 );
9128 }
9129
9130 #[tokio::test]
9131 async fn submit_order_denies_buy_one_raw_unit_above_quote_spend_limit_before_readiness() {
9132 let amount_in = expected_buy_amount_in();
9133 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
9134 config.quote_spend_limits.as_mut().unwrap()[0].max_amount =
9135 (amount_in - U256::from(1u8)).to_string();
9136 let (mut client, cache) = swap_client_with_cache(config);
9137 let order = test_market_buy_order(test_pool().instrument_id);
9138 cache
9139 .borrow_mut()
9140 .add_order(order.clone(), None, None, true)
9141 .unwrap();
9142 let mut receiver = start_with_events(&mut client);
9143
9144 client.submit_order(submit_order_cmd(&order)).unwrap();
9145
9146 let events = collect_order_events(&mut receiver);
9147 assert_eq!(events.len(), 1);
9148 let OrderEventAny::Denied(denied) = &events[0] else {
9149 panic!("expected OrderDenied, was {:?}", events[0]);
9150 };
9151 assert!(
9152 denied.reason.as_str().contains(&format!(
9153 "BUY quote amount {amount_in} exceeds the configured `quote_spend_limits`"
9154 )),
9155 "was: {}",
9156 denied.reason
9157 );
9158 assert!(!client.core.is_connected());
9159 assert!(!client.cache.has_database());
9160 assert!(client.signer.is_none());
9161 assert!(client.pending_tasks.is_empty());
9162 }
9163
9164 #[tokio::test]
9165 async fn submit_order_denies_non_market_order_type() {
9166 let (mut client, cache) =
9167 swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9168 let pool = test_pool();
9169 let order = OrderTestBuilder::new(OrderType::Limit)
9170 .trader_id(TraderId::from("TRADER-001"))
9171 .strategy_id(StrategyId::from("S-001"))
9172 .instrument_id(pool.instrument_id)
9173 .client_order_id(ClientOrderId::from("O-SWAP-001"))
9174 .side(OrderSide::Sell)
9175 .quantity(Quantity::from("0.001"))
9176 .price(Price::from("2000"))
9177 .build();
9178 cache
9179 .borrow_mut()
9180 .add_order(order.clone(), None, None, true)
9181 .unwrap();
9182 let mut receiver = start_with_events(&mut client);
9183
9184 client.submit_order(submit_order_cmd(&order)).unwrap();
9185
9186 let events = collect_order_events(&mut receiver);
9187 assert_eq!(events.len(), 1);
9188 let OrderEventAny::Denied(denied) = &events[0] else {
9189 panic!("expected OrderDenied, was {:?}", events[0]);
9190 };
9191 assert!(
9192 denied.reason.as_str().contains("only Market is supported"),
9193 "was: {}",
9194 denied.reason
9195 );
9196 }
9197
9198 #[tokio::test]
9199 async fn submit_order_denies_quote_denominated_quantity() {
9200 let (mut client, cache) =
9201 swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9202 let pool = test_pool();
9203 let order = OrderTestBuilder::new(OrderType::Market)
9204 .trader_id(TraderId::from("TRADER-001"))
9205 .strategy_id(StrategyId::from("S-001"))
9206 .instrument_id(pool.instrument_id)
9207 .client_order_id(ClientOrderId::from("O-SWAP-001"))
9208 .side(OrderSide::Sell)
9209 .quantity(Quantity::from("0.001"))
9210 .quote_quantity(true)
9211 .build();
9212 cache
9213 .borrow_mut()
9214 .add_order(order.clone(), None, None, true)
9215 .unwrap();
9216 let mut receiver = start_with_events(&mut client);
9217
9218 client.submit_order(submit_order_cmd(&order)).unwrap();
9219
9220 let events = collect_order_events(&mut receiver);
9221 assert_eq!(events.len(), 1);
9222 let OrderEventAny::Denied(denied) = &events[0] else {
9223 panic!("expected OrderDenied, was {:?}", events[0]);
9224 };
9225 assert!(
9226 denied.reason.as_str().contains("Quote-denominated"),
9227 "was: {}",
9228 denied.reason
9229 );
9230 }
9231
9232 #[tokio::test]
9233 async fn submit_order_denies_unknown_pool() {
9234 let (mut client, cache) =
9235 swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9236 let unknown: InstrumentId = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45.Arbitrum:UniswapV3"
9237 .parse()
9238 .unwrap();
9239 let order = OrderTestBuilder::new(OrderType::Market)
9240 .trader_id(TraderId::from("TRADER-001"))
9241 .strategy_id(StrategyId::from("S-001"))
9242 .instrument_id(unknown)
9243 .client_order_id(ClientOrderId::from("O-SWAP-001"))
9244 .side(OrderSide::Sell)
9245 .quantity(Quantity::from("0.001"))
9246 .build();
9247 cache
9248 .borrow_mut()
9249 .add_order(order.clone(), None, None, true)
9250 .unwrap();
9251 let mut receiver = start_with_events(&mut client);
9252
9253 client.submit_order(submit_order_cmd(&order)).unwrap();
9254
9255 let events = collect_order_events(&mut receiver);
9256 assert_eq!(events.len(), 1);
9257 let OrderEventAny::Denied(denied) = &events[0] else {
9258 panic!("expected OrderDenied, was {:?}", events[0]);
9259 };
9260 assert!(
9261 denied.reason.as_str().contains("Unknown pool"),
9262 "was: {}",
9263 denied.reason
9264 );
9265 }
9266
9267 #[tokio::test]
9268 async fn submit_order_denies_sell_when_only_buy_pair_allowlisted() {
9269 let mut config = test_config("http://127.0.0.1:1".to_string());
9270 config.allowed_token_pairs = Some(vec![(USDC.to_string(), WETH.to_string())]);
9271 let (mut client, _) = swap_client_with_cache(config);
9272 let order = test_market_sell_order(test_pool().instrument_id);
9273 let mut receiver = start_with_events(&mut client);
9274
9275 client.submit_order(submit_order_cmd(&order)).unwrap();
9276
9277 let events = collect_order_events(&mut receiver);
9278 assert_eq!(events.len(), 1);
9279 let OrderEventAny::Denied(denied) = &events[0] else {
9280 panic!("expected OrderDenied, was {:?}", events[0]);
9281 };
9282 assert!(
9283 denied
9284 .reason
9285 .as_str()
9286 .contains("not in the `allowed_token_pairs` allowlist"),
9287 "was: {}",
9288 denied.reason
9289 );
9290 assert!(
9291 denied.reason.as_str().contains(&WETH_ADDRESS.to_string()),
9292 "was: {}",
9293 denied.reason
9294 );
9295 }
9296
9297 #[tokio::test]
9298 async fn submit_order_sell_ignores_quote_spend_limits() {
9299 let mut config = test_config("http://127.0.0.1:1".to_string());
9300 config.quote_spend_limits = Some(vec![quote_spend_limit(WETH, USDC, 18, "0")]);
9301 let (mut client, _) = swap_client_with_cache(config);
9302 let order = test_market_sell_order(test_pool().instrument_id);
9303 let mut receiver = start_with_events(&mut client);
9304
9305 client.submit_order(submit_order_cmd(&order)).unwrap();
9306
9307 let events = collect_order_events(&mut receiver);
9308 assert_eq!(events.len(), 1);
9309 let OrderEventAny::Denied(denied) = &events[0] else {
9310 panic!("expected OrderDenied, was {:?}", events[0]);
9311 };
9312 assert!(
9313 denied
9314 .reason
9315 .as_str()
9316 .contains("Blockchain execution client is not connected"),
9317 "was: {}",
9318 denied.reason
9319 );
9320 }
9321
9322 #[tokio::test]
9323 async fn submit_order_denies_token_pair_outside_allowlist() {
9324 let mut config = test_config("http://127.0.0.1:1".to_string());
9325 config.allowed_token_pairs = Some(Vec::new());
9326 let (mut client, _) = swap_client_with_cache(config);
9327 let order = test_market_sell_order(test_pool().instrument_id);
9328 let mut receiver = start_with_events(&mut client);
9329
9330 client.submit_order(submit_order_cmd(&order)).unwrap();
9331
9332 let events = collect_order_events(&mut receiver);
9333 assert_eq!(events.len(), 1);
9334 let OrderEventAny::Denied(denied) = &events[0] else {
9335 panic!("expected OrderDenied, was {:?}", events[0]);
9336 };
9337 assert!(
9338 denied
9339 .reason
9340 .as_str()
9341 .contains("not in the `allowed_token_pairs` allowlist"),
9342 "was: {}",
9343 denied.reason
9344 );
9345 }
9346
9347 #[tokio::test]
9348 async fn submit_order_denies_amount_above_max_order_amount() {
9349 let mut config = test_config("http://127.0.0.1:1".to_string());
9350 config.max_order_amount = Some(999_999_999_999_999); let (mut client, _) = swap_client_with_cache(config);
9352 let order = test_market_sell_order(test_pool().instrument_id);
9353 let mut receiver = start_with_events(&mut client);
9354
9355 client.submit_order(submit_order_cmd(&order)).unwrap();
9356
9357 let events = collect_order_events(&mut receiver);
9358 assert_eq!(events.len(), 1);
9359 let OrderEventAny::Denied(denied) = &events[0] else {
9360 panic!("expected OrderDenied, was {:?}", events[0]);
9361 };
9362 assert!(
9363 denied
9364 .reason
9365 .as_str()
9366 .contains("exceeds the configured `max_order_amount`"),
9367 "was: {}",
9368 denied.reason
9369 );
9370 }
9371
9372 #[tokio::test]
9373 async fn submit_order_denies_slippage_param_above_ceiling() {
9374 let (mut client, _) = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9375 let order = test_market_sell_order(test_pool().instrument_id);
9376 let mut cmd = submit_order_cmd(&order);
9377 cmd.params = Some(serde_json::from_str(r#"{"slippage_bps": 201}"#).unwrap());
9378 let mut receiver = start_with_events(&mut client);
9379
9380 client.submit_order(cmd).unwrap();
9381
9382 let events = collect_order_events(&mut receiver);
9383 assert_eq!(events.len(), 1);
9384 let OrderEventAny::Denied(denied) = &events[0] else {
9385 panic!("expected OrderDenied, was {:?}", events[0]);
9386 };
9387 assert!(
9388 denied
9389 .reason
9390 .as_str()
9391 .contains("exceeds the configured `max_slippage_bps`"),
9392 "was: {}",
9393 denied.reason
9394 );
9395 }
9396
9397 #[tokio::test]
9398 async fn submit_order_denies_pool_without_fee_tier() {
9399 let addr = start_mock_rpc_server(MockRpcState::default()).await;
9400 let mut pool = test_pool();
9401 pool.fee = None;
9402 let cache = Rc::new(RefCell::new(Cache::default()));
9403 cache.borrow_mut().add_pool(pool.clone()).unwrap();
9404 let order = test_market_sell_order(pool.instrument_id);
9405 cache
9406 .borrow_mut()
9407 .add_order(order.clone(), None, None, false)
9408 .unwrap();
9409 let core = ExecutionClientCore::new(
9410 TraderId::from("TRADER-001"),
9411 ClientId::from("BLOCKCHAIN-001"),
9412 *BLOCKCHAIN_VENUE,
9413 OmsType::Netting,
9414 AccountId::from("BLOCKCHAIN-001"),
9415 AccountType::Wallet,
9416 None,
9417 cache,
9418 );
9419 let mut client =
9420 BlockchainExecutionClient::new(core, test_config(format!("http://{addr}"))).unwrap();
9421 let mut receiver = start_with_events(&mut client);
9422
9423 client.submit_order(submit_order_cmd(&order)).unwrap();
9424
9425 let events = collect_order_events(&mut receiver);
9426 assert_eq!(events.len(), 1);
9427 let OrderEventAny::Denied(denied) = &events[0] else {
9428 panic!("expected OrderDenied, was {:?}", events[0]);
9429 };
9430 assert!(
9431 denied.reason.as_str().contains("no fee tier"),
9432 "was: {}",
9433 denied.reason
9434 );
9435 }
9436
9437 #[tokio::test]
9438 async fn submit_order_denies_without_live_profiler() {
9439 let addr = start_mock_rpc_server(MockRpcState::default()).await;
9440 let cache = Rc::new(RefCell::new(Cache::default()));
9441 let pool = test_pool();
9442 cache.borrow_mut().add_pool(pool.clone()).unwrap();
9443 let order = test_market_sell_order(pool.instrument_id);
9444 cache
9445 .borrow_mut()
9446 .add_order(order.clone(), None, None, false)
9447 .unwrap();
9448 let core = ExecutionClientCore::new(
9449 TraderId::from("TRADER-001"),
9450 ClientId::from("BLOCKCHAIN-001"),
9451 *BLOCKCHAIN_VENUE,
9452 OmsType::Netting,
9453 AccountId::from("BLOCKCHAIN-001"),
9454 AccountType::Wallet,
9455 None,
9456 cache,
9457 );
9458 let mut client =
9459 BlockchainExecutionClient::new(core, test_config(format!("http://{addr}"))).unwrap();
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.as_str().contains("No pool profiler"),
9471 "was: {}",
9472 denied.reason
9473 );
9474 }
9475
9476 #[tokio::test]
9477 async fn submit_order_denies_when_not_connected() {
9478 let (mut client, _) = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9479 let order = test_market_sell_order(test_pool().instrument_id);
9480 let mut receiver = start_with_events(&mut client);
9481
9482 client.submit_order(submit_order_cmd(&order)).unwrap();
9483
9484 let events = collect_order_events(&mut receiver);
9485 assert_eq!(events.len(), 1);
9486 let OrderEventAny::Denied(denied) = &events[0] else {
9487 panic!("expected OrderDenied, was {:?}", events[0]);
9488 };
9489 assert!(
9490 denied.reason.as_str().contains("is not connected"),
9491 "was: {}",
9492 denied.reason
9493 );
9494 }
9495
9496 #[tokio::test]
9497 async fn submit_order_denies_without_durable_store() {
9498 let (mut client, _) = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9499 client.core.set_connected();
9500 let order = test_market_sell_order(test_pool().instrument_id);
9501 let mut receiver = start_with_events(&mut client);
9502
9503 client.submit_order(submit_order_cmd(&order)).unwrap();
9504
9505 let events = collect_order_events(&mut receiver);
9506 assert_eq!(events.len(), 1);
9507 let OrderEventAny::Denied(denied) = &events[0] else {
9508 panic!("expected OrderDenied, was {:?}", events[0]);
9509 };
9510 assert!(
9511 denied
9512 .reason
9513 .as_str()
9514 .contains("No durable store configured"),
9515 "was: {}",
9516 denied.reason
9517 );
9518 }
9519
9520 #[tokio::test]
9521 async fn submit_order_denies_when_transaction_in_flight() {
9522 let (mut client, _) = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9523 client.core.set_connected();
9524 *client.in_flight.lock() = Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
9525 intent_id: 1,
9526 nonce: 7,
9527 tx_hash: B256::ZERO,
9528 purpose: TransactionPurpose::Wrap,
9529 }));
9530 let order = test_market_sell_order(test_pool().instrument_id);
9531 let mut receiver = start_with_events(&mut client);
9532
9533 client.submit_order(submit_order_cmd(&order)).unwrap();
9534
9535 let events = collect_order_events(&mut receiver);
9536 assert_eq!(events.len(), 1);
9537 let OrderEventAny::Denied(denied) = &events[0] else {
9538 panic!("expected OrderDenied, was {:?}", events[0]);
9539 };
9540 assert!(
9541 denied.reason.as_str().contains("still awaiting finality"),
9542 "was: {}",
9543 denied.reason
9544 );
9545 }
9546
9547 #[tokio::test]
9548 async fn submit_order_denies_without_signer() {
9549 let Some((admin_pool, schema, mut client, _, _)) =
9550 swap_client_with_database("execution_submit_no_signer_test", swap_rpc_state().await)
9551 .await
9552 else {
9553 return;
9554 };
9555 client.signer = None;
9556 let order = test_market_sell_order(test_pool().instrument_id);
9557 let mut receiver = start_with_events(&mut client);
9558
9559 client.submit_order(submit_order_cmd(&order)).unwrap();
9560
9561 let events = collect_order_events(&mut receiver);
9562 assert_eq!(events.len(), 1);
9563 let OrderEventAny::Denied(denied) = &events[0] else {
9564 panic!("expected OrderDenied, was {:?}", events[0]);
9565 };
9566 assert!(
9567 denied.reason.as_str().contains("Signer not initialized"),
9568 "was: {}",
9569 denied.reason
9570 );
9571
9572 drop_execution_schema(&admin_pool, &schema).await;
9573 }
9574
9575 #[tokio::test]
9576 async fn submit_order_broadcasts_swap_and_records_client_order_id() {
9577 let Some((admin_pool, schema, mut client, state, _)) =
9578 swap_client_with_database("execution_submit_success_test", swap_rpc_state().await)
9579 .await
9580 else {
9581 return;
9582 };
9583 let order = test_market_sell_order(test_pool().instrument_id);
9584 let mut receiver = start_with_events(&mut client);
9585 let expected_min_out = expected_min_amount_out(50);
9586
9587 client.submit_order(submit_order_cmd(&order)).unwrap();
9588 await_pending_tasks(&client).await;
9589
9590 let events = collect_order_events(&mut receiver);
9591 assert_swap_submitted_and_filled(&events);
9592 let OrderEventAny::Submitted(submitted) = &events[0] else {
9593 panic!("expected OrderSubmitted, was {:?}", events[0]);
9594 };
9595 assert_eq!(submitted.client_order_id, order.client_order_id());
9596
9597 let (expected_hash, expected_raw) = expected_swap_tx(expected_min_out).await;
9599 let broadcasts: Vec<_> = state
9600 .recorded_requests()
9601 .into_iter()
9602 .filter(|request| request["method"] == "eth_sendRawTransaction")
9603 .collect();
9604 assert_eq!(broadcasts.len(), 1);
9605 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
9606
9607 let record = client
9608 .cache
9609 .get_execution_transaction(42161, &expected_hash.to_string())
9610 .await
9611 .unwrap()
9612 .unwrap();
9613 assert_eq!(record.nonce, 7);
9614 assert_eq!(record.purpose, "swap");
9615 assert_eq!(record.status, "finalized");
9616 assert_eq!(
9617 record.client_order_id.as_deref(),
9618 Some(order.client_order_id().as_str())
9619 );
9620 assert!(client.in_flight.lock().is_none());
9621 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
9622 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
9623 )))
9624 .fetch_one(&admin_pool)
9625 .await
9626 .unwrap();
9627 let decision_count = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(format!(
9628 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
9629 WHERE outcome = 'verified'"
9630 )))
9631 .fetch_one(&admin_pool)
9632 .await
9633 .unwrap();
9634 let connect_decision_count = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(format!(
9635 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
9636 WHERE decision_class = 'connect' AND outcome = 'verified'"
9637 )))
9638 .fetch_one(&admin_pool)
9639 .await
9640 .unwrap();
9641 assert_eq!(nonce_state, (8, 1));
9642 assert_eq!(decision_count, 35);
9643 assert_eq!(connect_decision_count, 1);
9644
9645 drop_execution_schema(&admin_pool, &schema).await;
9646 }
9647
9648 #[tokio::test]
9649 async fn submit_order_pins_every_pre_sign_state_read_to_swap_anchor() {
9650 let Some((admin_pool, schema, mut client, state, _)) =
9651 swap_client_with_database("execution_submit_anchor_reads_test", swap_rpc_state().await)
9652 .await
9653 else {
9654 return;
9655 };
9656 let order = test_market_sell_order(test_pool().instrument_id);
9657 let mut receiver = start_with_events(&mut client);
9658
9659 client.submit_order(submit_order_cmd(&order)).unwrap();
9660 await_pending_tasks(&client).await;
9661
9662 let events = collect_order_events(&mut receiver);
9663 assert_swap_submitted_and_filled(&events);
9664 let requests = state.recorded_requests();
9665 let broadcast_index = requests
9666 .iter()
9667 .position(|request| request["method"] == "eth_sendRawTransaction")
9668 .unwrap();
9669 let pre_broadcast = &requests[..broadcast_index];
9670 let latest_reads: Vec<_> = pre_broadcast
9671 .iter()
9672 .filter(|request| {
9673 request["method"] == "eth_getBlockByNumber" && request["params"][0] == "latest"
9674 })
9675 .collect();
9676 assert_eq!(latest_reads.len(), 3);
9677 assert!(
9678 latest_reads
9679 .iter()
9680 .all(|request| request["params"] == serde_json::json!(["latest", false]))
9681 );
9682
9683 for method in [
9684 "eth_getCode",
9685 "eth_call",
9686 "eth_estimateGas",
9687 "eth_getBalance",
9688 ] {
9689 let pinned: Vec<_> = pre_broadcast
9690 .iter()
9691 .filter(|request| request["method"] == method)
9692 .collect();
9693 assert!(!pinned.is_empty(), "method {method}");
9694 assert_eq!(pinned.len() % 3, 0, "method {method}: {pinned:?}");
9695 assert!(
9696 pinned
9697 .iter()
9698 .all(|request| request["params"][1] == FIXTURE_BLOCK_PARAM),
9699 "method {method}: {pinned:?}"
9700 );
9701 }
9702
9703 let numbered_blocks: Vec<_> = pre_broadcast
9704 .iter()
9705 .filter(|request| {
9706 request["method"] == "eth_getBlockByNumber"
9707 && request["params"][0] == FIXTURE_BLOCK_PARAM
9708 })
9709 .collect();
9710 assert!(!numbered_blocks.is_empty());
9711 assert_eq!(numbered_blocks.len() % 3, 0);
9712 assert!(
9713 numbered_blocks
9714 .iter()
9715 .all(|request| request["params"][1] == false)
9716 );
9717
9718 let chain_ids: Vec<_> = pre_broadcast
9719 .iter()
9720 .filter(|request| request["method"] == "eth_chainId")
9721 .collect();
9722 assert_eq!(chain_ids.len(), 3);
9723 assert!(
9724 chain_ids
9725 .iter()
9726 .all(|request| request["params"] == serde_json::json!([]))
9727 );
9728 let nonces: Vec<_> = pre_broadcast
9729 .iter()
9730 .filter(|request| request["method"] == "eth_getTransactionCount")
9731 .collect();
9732 assert_eq!(nonces.len(), 6);
9733 assert_eq!(
9734 nonces
9735 .iter()
9736 .filter(|request| {
9737 request["params"] == serde_json::json!([WALLET.to_ascii_lowercase(), "pending"])
9738 })
9739 .count(),
9740 3
9741 );
9742 assert_eq!(
9743 nonces
9744 .iter()
9745 .filter(|request| {
9746 request["params"]
9747 == serde_json::json!([WALLET.to_ascii_lowercase(), FIXTURE_BLOCK_PARAM])
9748 })
9749 .count(),
9750 3
9751 );
9752 let priority_fees: Vec<_> = pre_broadcast
9753 .iter()
9754 .filter(|request| request["method"] == "eth_maxPriorityFeePerGas")
9755 .collect();
9756 assert_eq!(priority_fees.len(), 3);
9757 assert!(
9758 priority_fees
9759 .iter()
9760 .all(|request| request["params"] == serde_json::json!([]))
9761 );
9762
9763 let (_, expected_raw) = expected_swap_tx(expected_min_amount_out(50)).await;
9764 assert_eq!(
9765 requests[broadcast_index]["params"][0].as_str().unwrap(),
9766 expected_raw
9767 );
9768 let created_block: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
9769 "SELECT created_block FROM {schema}.execution_intent"
9770 )))
9771 .fetch_one(&admin_pool)
9772 .await
9773 .unwrap();
9774 assert_eq!(created_block, i64::try_from(FIXTURE_BLOCK).unwrap());
9775
9776 drop_execution_schema(&admin_pool, &schema).await;
9777 }
9778
9779 #[tokio::test]
9780 async fn submit_order_denies_changed_swap_anchor_before_signing() {
9781 let canonical = fixture_block_response(FIXTURE_BLOCK, B256::from([0x11; 32]));
9782 let changed = fixture_block_response(FIXTURE_BLOCK, B256::from([0x44; 32]));
9783 let state = swap_rpc_state().await.with_parameter_response_sequence(
9784 "eth_getBlockByNumber",
9785 FIXTURE_BLOCK_PARAM,
9786 &[
9787 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9788 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9789 &canonical, &changed, &changed, &changed,
9790 ],
9791 );
9792 let Some((admin_pool, schema, mut client, state, _)) =
9793 swap_client_with_database("execution_submit_anchor_change_test", state).await
9794 else {
9795 return;
9796 };
9797 let order = test_market_sell_order(test_pool().instrument_id);
9798 let mut receiver = start_with_events(&mut client);
9799
9800 client.submit_order(submit_order_cmd(&order)).unwrap();
9801 await_pending_tasks(&client).await;
9802
9803 let events = collect_order_events(&mut receiver);
9804 assert_eq!(events.len(), 1, "was: {events:?}");
9805 let OrderEventAny::Denied(denied) = &events[0] else {
9806 panic!("expected OrderDenied, was {:?}", events[0]);
9807 };
9808 assert!(
9809 denied
9810 .reason
9811 .as_str()
9812 .contains("pre-sign checkpoint reread verification disagreed"),
9813 "was: {}",
9814 denied.reason
9815 );
9816 assert_eq!(
9817 execution_intent_markers(&admin_pool, &schema).await,
9818 vec![("swap".into(), "recoverable".into(), false, false)]
9819 );
9820 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
9821 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
9822 )))
9823 .fetch_one(&admin_pool)
9824 .await
9825 .unwrap();
9826 assert_eq!(signed_count, 0);
9827 assert!(
9828 state
9829 .recorded_requests()
9830 .iter()
9831 .all(|request| { request["method"] != "eth_sendRawTransaction" })
9832 );
9833 assert!(client.in_flight.lock().is_none());
9834
9835 drop_execution_schema(&admin_pool, &schema).await;
9836 }
9837
9838 #[tokio::test]
9839 async fn submit_order_keeps_verified_swap_anchor_when_latest_advances() {
9840 let canonical = fixture_block_response(FIXTURE_BLOCK, B256::from([0x11; 32]));
9841 let newer = fixture_block_response(FIXTURE_BLOCK + 1, B256::from([0x44; 32]));
9842 let state = swap_rpc_state().await.with_parameter_response_sequence(
9843 "eth_getBlockByNumber",
9844 "latest",
9845 &[&canonical, &canonical, &canonical, &newer, &newer, &newer],
9846 );
9847 let Some((admin_pool, schema, mut client, state, _)) =
9848 swap_client_with_database("execution_submit_advancing_head_test", state).await
9849 else {
9850 return;
9851 };
9852 let order = test_market_sell_order(test_pool().instrument_id);
9853 let mut receiver = start_with_events(&mut client);
9854
9855 client.submit_order(submit_order_cmd(&order)).unwrap();
9856 await_pending_tasks(&client).await;
9857
9858 let events = collect_order_events(&mut receiver);
9859 assert_swap_submitted_and_filled(&events);
9860 let latest_reads = state
9861 .recorded_requests()
9862 .iter()
9863 .filter(|request| {
9864 request["method"] == "eth_getBlockByNumber" && request["params"][0] == "latest"
9865 })
9866 .count();
9867 assert_eq!(latest_reads, 3);
9868
9869 drop_execution_schema(&admin_pool, &schema).await;
9870 }
9871
9872 #[tokio::test]
9873 async fn submit_order_denies_changed_quote_watermark_before_signing() {
9874 let watermark_number = FIXTURE_BLOCK;
9875 let watermark_hash = B256::from([0x11; 32]);
9876 let canonical = fixture_block_response(watermark_number, watermark_hash);
9877 let changed = fixture_block_response(watermark_number, B256::from([0x66; 32]));
9878 let watermark_param = format!("0x{watermark_number:x}");
9879 let min_amount_out = expected_min_amount_out(50);
9880 let (tx_hash, _) = expected_swap_tx(min_amount_out).await;
9881 let head = finalized_swap_block(tx_hash, min_amount_out);
9882 let state = swap_rpc_state()
9883 .await
9884 .with_response("eth_getBlockByNumber", &head)
9885 .with_parameter_response_sequence(
9886 "eth_getBlockByNumber",
9887 &watermark_param,
9888 &[
9889 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9890 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9891 &changed, &changed, &changed,
9892 ],
9893 );
9894 let Some((admin_pool, schema, mut client, state, cache)) =
9895 swap_client_with_database("execution_submit_watermark_change_test", state).await
9896 else {
9897 return;
9898 };
9899 let pool = test_pool();
9900 cache
9901 .borrow_mut()
9902 .add_pool_profiler(test_profiler_at_block(
9903 &pool,
9904 watermark_number,
9905 &watermark_hash.to_string(),
9906 ))
9907 .unwrap();
9908 let order = test_market_sell_order(pool.instrument_id);
9909 let mut receiver = start_with_events(&mut client);
9910
9911 client.submit_order(submit_order_cmd(&order)).unwrap();
9912 await_pending_tasks(&client).await;
9913
9914 let events = collect_order_events(&mut receiver);
9915 assert_eq!(events.len(), 1, "was: {events:?}");
9916 let OrderEventAny::Denied(denied) = &events[0] else {
9917 panic!("expected OrderDenied, was {:?}", events[0]);
9918 };
9919 assert!(
9920 denied.reason.as_str().contains("changed before signing"),
9921 "was: {}",
9922 denied.reason
9923 );
9924 assert_eq!(
9925 execution_intent_markers(&admin_pool, &schema).await,
9926 vec![("swap".into(), "recoverable".into(), false, false)]
9927 );
9928 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
9929 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
9930 )))
9931 .fetch_one(&admin_pool)
9932 .await
9933 .unwrap();
9934 assert_eq!(signed_count, 0);
9935 assert!(
9936 state
9937 .recorded_requests()
9938 .iter()
9939 .all(|request| { request["method"] != "eth_sendRawTransaction" })
9940 );
9941 assert!(client.in_flight.lock().is_none());
9942
9943 drop_execution_schema(&admin_pool, &schema).await;
9944 }
9945
9946 #[tokio::test]
9947 async fn changed_swap_anchor_retains_ownership_when_recovery_commit_fails() {
9948 let canonical = fixture_block_response(FIXTURE_BLOCK, B256::from([0x11; 32]));
9949 let changed = fixture_block_response(FIXTURE_BLOCK, B256::from([0x44; 32]));
9950 let state = swap_rpc_state().await.with_parameter_response_sequence(
9951 "eth_getBlockByNumber",
9952 FIXTURE_BLOCK_PARAM,
9953 &[
9954 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9955 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9956 &canonical, &changed, &changed, &changed,
9957 ],
9958 );
9959 let Some((admin_pool, schema, mut client, state, _)) =
9960 swap_client_with_database("execution_submit_anchor_recovery_fail_test", state).await
9961 else {
9962 return;
9963 };
9964 install_recoverable_commit_rejection(&admin_pool, &schema).await;
9965 let order = test_market_sell_order(test_pool().instrument_id);
9966 let mut receiver = start_with_events(&mut client);
9967
9968 client.submit_order(submit_order_cmd(&order)).unwrap();
9969 await_pending_tasks(&client).await;
9970
9971 let events = collect_order_events(&mut receiver);
9972 assert_eq!(events.len(), 1, "was: {events:?}");
9973 let OrderEventAny::Denied(denied) = &events[0] else {
9974 panic!("expected OrderDenied, was {:?}", events[0]);
9975 };
9976 assert!(
9977 denied
9978 .reason
9979 .as_str()
9980 .contains("pre-sign checkpoint reread verification disagreed"),
9981 "was: {}",
9982 denied.reason
9983 );
9984 assert_eq!(
9985 execution_intent_markers(&admin_pool, &schema).await,
9986 vec![("swap".into(), "prepared".into(), false, true)]
9987 );
9988 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
9989 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
9990 )))
9991 .fetch_one(&admin_pool)
9992 .await
9993 .unwrap();
9994 assert_eq!(signed_count, 0);
9995 assert!(
9996 state
9997 .recorded_requests()
9998 .iter()
9999 .all(|request| { request["method"] != "eth_sendRawTransaction" })
10000 );
10001 assert!(matches!(
10002 *client.in_flight.lock(),
10003 Some(InFlightSlot::Preparing(TransactionPurpose::Swap))
10004 ));
10005
10006 drop_execution_schema(&admin_pool, &schema).await;
10007 }
10008
10009 #[tokio::test]
10010 async fn finalized_swap_emits_exact_fill_once_and_refreshes_wallet() {
10011 let min_amount_out = expected_min_amount_out(50);
10012 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
10013 let state = finalized_swap_rpc_state(expected_hash, min_amount_out);
10014 let Some((admin_pool, schema, mut client, state, _)) =
10015 swap_client_with_database("execution_submit_fill_test", state).await
10016 else {
10017 return;
10018 };
10019 let order = test_market_sell_order(test_pool().instrument_id);
10020 let mut receiver = start_with_events(&mut client);
10021
10022 let plan = client
10023 .prepare_swap(&submit_order_cmd(&order), &order)
10024 .unwrap();
10025 execute_swap(
10026 plan,
10027 client.transaction_executor().unwrap(),
10028 client.emitter.clone(),
10029 client.transaction_limits.max_quote_age_blocks,
10030 client.transaction_limits.deadline_seconds,
10031 )
10032 .await
10033 .unwrap();
10034
10035 let (nonce, wallet_address, transaction_to, transaction_input, transaction_value): (
10036 i64,
10037 String,
10038 String,
10039 String,
10040 String,
10041 ) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10042 "SELECT nonce, wallet_address, transaction_to, transaction_input, transaction_value \
10043 FROM {schema}.execution_intent"
10044 )))
10045 .fetch_one(&admin_pool)
10046 .await
10047 .unwrap();
10048 let finalized_block = client
10049 .http_rpc_client
10050 .block_by_number(FIXTURE_BLOCK + 1, true)
10051 .await
10052 .unwrap();
10053 let finalized_transaction = finalized_block
10054 .transactions
10055 .iter()
10056 .find(|transaction| transaction.hash == expected_hash)
10057 .unwrap();
10058 assert_eq!(finalized_transaction.from.to_string(), wallet_address);
10059 assert_eq!(finalized_transaction.nonce, u64::try_from(nonce).unwrap());
10060 assert_eq!(
10061 finalized_transaction.to,
10062 Some(Address::from_str(&transaction_to).unwrap())
10063 );
10064 assert_eq!(
10065 finalized_transaction.input.as_ref(),
10066 hex::decode(transaction_input.strip_prefix("0x").unwrap()).unwrap()
10067 );
10068 assert_eq!(
10069 finalized_transaction.value,
10070 U256::from_str(&transaction_value).unwrap()
10071 );
10072
10073 let mut order_events = Vec::new();
10074 let mut account_states = Vec::new();
10075
10076 while let Ok(event) = receiver.try_recv() {
10077 match event {
10078 ExecutionEvent::Order(event) => order_events.push(event),
10079 ExecutionEvent::Account(state) => account_states.push(state),
10080 other => panic!("unexpected execution event: {other:?}"),
10081 }
10082 }
10083 assert_eq!(order_events.len(), 2, "was: {order_events:?}");
10084 assert!(matches!(&order_events[0], OrderEventAny::Submitted(_)));
10085 let OrderEventAny::Filled(fill) = &order_events[1] else {
10086 panic!("expected OrderFilled, was {:?}", order_events[1]);
10087 };
10088 let expected_commission = Money::from_u256(
10089 U256::from(50_112_u64) * U256::from(100_000_000_u64),
10090 test_pool().chain.native_currency(),
10091 )
10092 .unwrap();
10093 assert_eq!(fill.client_order_id, order.client_order_id());
10094 assert_eq!(fill.venue_order_id.as_str(), expected_hash.to_string());
10095 assert_eq!(fill.order_side, OrderSide::Sell);
10096 assert_eq!(fill.last_qty, Quantity::from("0.001"));
10097 assert_eq!(fill.last_px, Price::from("1000"));
10098 assert_eq!(fill.currency.code.as_str(), "USDC");
10099 assert_eq!(fill.commission, Some(expected_commission));
10100 assert_eq!(fill.liquidity_side, LiquiditySide::Taker);
10101 assert_eq!(account_states.len(), 1);
10102 let account_state = &account_states[0];
10103 assert_eq!(account_state.account_id, AccountId::from("BLOCKCHAIN-001"));
10104 assert_eq!(account_state.account_type, AccountType::Wallet);
10105 assert_eq!(account_state.base_currency, None);
10106 assert_eq!(account_state.balances.len(), 3);
10107 assert!(account_state.margins.is_empty());
10108 assert!(account_state.is_reported);
10109 assert_eq!(
10110 account_state.balances,
10111 client.wallet_balance.lock().as_account_balances().unwrap()
10112 );
10113
10114 let (fill_emitted, active): (bool, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10115 "SELECT fill_emitted, active FROM {schema}.execution_intent"
10116 )))
10117 .fetch_one(&admin_pool)
10118 .await
10119 .unwrap();
10120 assert!(fill_emitted);
10121 assert!(!active);
10122
10123 let database = client.cache.database.as_ref().unwrap().clone();
10124 let payload_keys = client.payload_keys.clone();
10125 let restart_config = client.config.clone();
10126 drop(client);
10127 let (mut restarted, _) = swap_client_with_cache(restart_config);
10128 restarted.cache.database = Some(database);
10129 restarted.payload_keys = payload_keys;
10130 restarted.signer = Some(Arc::new(
10131 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
10132 ));
10133 let mut restart_receiver = start_with_events(&mut restarted);
10134 restarted.reconcile_unresolved_execution().await.unwrap();
10135 restarted.reconcile_unresolved_execution().await.unwrap();
10136 assert!(collect_order_events(&mut restart_receiver).is_empty());
10137 let requests = state.recorded_requests();
10138 assert_eq!(
10139 requests
10140 .iter()
10141 .filter(|request| request["method"] == "eth_sendRawTransaction")
10142 .count(),
10143 1
10144 );
10145 assert_eq!(
10146 requests
10147 .iter()
10148 .filter(|request| {
10149 request["method"] == "eth_call"
10150 && request["params"][0]["data"]
10151 .as_str()
10152 .is_some_and(|data| data.starts_with(BALANCE_OF_SELECTOR))
10153 })
10154 .count(),
10155 9
10156 );
10157 assert_eq!(
10158 requests
10159 .iter()
10160 .filter(|request| request["method"] == "eth_getBalance")
10161 .count(),
10162 6
10163 );
10164
10165 drop_execution_schema(&admin_pool, &schema).await;
10166 }
10167
10168 #[tokio::test]
10169 async fn restart_emits_committed_swap_from_verified_inclusion_header() {
10170 let min_amount_out = expected_min_amount_out(50);
10171 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
10172 let state = finalized_swap_rpc_state(expected_hash, min_amount_out);
10173 let Some((admin_pool, schema, mut client, _, _)) =
10174 swap_client_with_database("execution_committed_fill_restart_test", state).await
10175 else {
10176 return;
10177 };
10178 let order = test_market_sell_order(test_pool().instrument_id);
10179 let mut receiver = start_with_events(&mut client);
10180 let plan = client
10181 .prepare_swap(&submit_order_cmd(&order), &order)
10182 .unwrap();
10183
10184 execute_swap(
10185 plan,
10186 client.transaction_executor().unwrap(),
10187 client.emitter.clone(),
10188 client.transaction_limits.max_quote_age_blocks,
10189 client.transaction_limits.deadline_seconds,
10190 )
10191 .await
10192 .unwrap();
10193 assert_swap_submitted_and_filled(&collect_order_events(&mut receiver));
10194 let ancestry_range: (i64, i64) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10195 "SELECT height_start, height_end FROM {schema}.execution_verification_decision \
10196 WHERE decision_class = 'finality' AND read_class = 'numbered_block' \
10197 AND height_end > height_start"
10198 )))
10199 .fetch_one(&admin_pool)
10200 .await
10201 .unwrap();
10202 assert_eq!(
10203 ancestry_range,
10204 ((FIXTURE_BLOCK + 1) as i64, (FIXTURE_BLOCK + 2) as i64)
10205 );
10206
10207 sqlx::query(sqlx::AssertSqlSafe(format!(
10208 "UPDATE {schema}.execution_intent SET fill_emitted = FALSE, active = TRUE"
10209 )))
10210 .execute(&admin_pool)
10211 .await
10212 .unwrap();
10213 let database = client.cache.database.as_ref().unwrap().clone();
10214 let payload_keys = client.payload_keys.clone();
10215 let restart_config = client.config.clone();
10216 drop(client);
10217 let (mut restarted, _) = swap_client_with_cache(restart_config);
10218 restarted.cache.database = Some(database);
10219 restarted.payload_keys = payload_keys;
10220 restarted.signer = Some(Arc::new(
10221 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
10222 ));
10223 let mut restart_receiver = start_with_events(&mut restarted);
10224
10225 restarted.reconcile_unresolved_execution().await.unwrap();
10226
10227 let events = collect_order_events(&mut restart_receiver);
10228 let (fill_emitted, active): (bool, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10229 "SELECT fill_emitted, active FROM {schema}.execution_intent"
10230 )))
10231 .fetch_one(&admin_pool)
10232 .await
10233 .unwrap();
10234 assert_eq!(events.len(), 1, "was: {events:?}");
10235 assert!(matches!(&events[0], OrderEventAny::Filled(_)));
10236 assert!(fill_emitted);
10237 assert!(!active);
10238
10239 drop_execution_schema(&admin_pool, &schema).await;
10240 }
10241
10242 #[tokio::test]
10243 async fn finalized_swap_ignores_unrelated_swap_logs() {
10244 let min_amount_out = expected_min_amount_out(50);
10245 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
10246 let receipt = finalized_swap_receipt_with_unrelated_swap(expected_hash);
10247 let state = finalized_swap_rpc_state(expected_hash, min_amount_out)
10248 .with_response("eth_getTransactionReceipt", &receipt);
10249 let Some((admin_pool, schema, mut client, _, _)) =
10250 swap_client_with_database("execution_submit_unrelated_log_test", state).await
10251 else {
10252 return;
10253 };
10254 let order = test_market_sell_order(test_pool().instrument_id);
10255 let mut receiver = start_with_events(&mut client);
10256
10257 client.submit_order(submit_order_cmd(&order)).unwrap();
10258 await_pending_tasks(&client).await;
10259
10260 let events = collect_order_events(&mut receiver);
10261 assert_swap_submitted_and_filled(&events);
10262
10263 drop_execution_schema(&admin_pool, &schema).await;
10264 }
10265
10266 #[tokio::test]
10267 async fn prepare_swap_buy_accepts_quote_spend_exact_boundary() {
10268 let amount_in = expected_buy_amount_in();
10269 let min_amount_out = expected_buy_min_amount_out(50);
10270 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10271 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10272 let max_amount = amount_in.to_string();
10273 let Some((admin_pool, schema, client, _, cache)) = swap_client_with_database_config(
10274 "execution_prepare_buy_test",
10275 state,
10276 move |http_rpc_url| {
10277 let mut config = buy_test_config(http_rpc_url);
10278 config.quote_spend_limits.as_mut().unwrap()[0].max_amount = max_amount;
10279 config
10280 },
10281 )
10282 .await
10283 else {
10284 return;
10285 };
10286 let order = test_market_buy_order(test_pool().instrument_id);
10287 cache
10288 .borrow_mut()
10289 .add_order(order.clone(), None, None, true)
10290 .unwrap();
10291
10292 let plan = client
10293 .prepare_swap(&submit_order_cmd(&order), &order)
10294 .unwrap();
10295
10296 assert_eq!(plan.token_in, USDC_ADDRESS);
10297 assert_eq!(plan.token_out, WETH_ADDRESS);
10298 assert_eq!(plan.amount_in, amount_in);
10299 assert_eq!(plan.min_amount_out, min_amount_out);
10300 assert_ne!(plan.token_in, WETH_ADDRESS);
10301
10302 drop_execution_schema(&admin_pool, &schema).await;
10303 }
10304
10305 #[tokio::test]
10306 async fn submit_order_broadcasts_buy_swap() {
10307 let amount_in = expected_buy_amount_in();
10308 let min_amount_out = expected_buy_min_amount_out(50);
10309 let (expected_hash, expected_raw) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10310 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10311 let Some((admin_pool, schema, mut client, state, cache)) =
10312 swap_client_with_buy_database("execution_submit_buy_success_test", state).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 let mut receiver = start_with_events(&mut client);
10322
10323 client.submit_order(submit_order_cmd(&order)).unwrap();
10324 await_pending_tasks(&client).await;
10325
10326 let events = collect_order_events(&mut receiver);
10327 assert_swap_submitted_and_filled(&events);
10328 let broadcasts: Vec<_> = state
10329 .recorded_requests()
10330 .into_iter()
10331 .filter(|request| request["method"] == "eth_sendRawTransaction")
10332 .collect();
10333 assert_eq!(broadcasts.len(), 1);
10334 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
10335
10336 drop_execution_schema(&admin_pool, &schema).await;
10337 }
10338
10339 #[tokio::test]
10340 async fn finalized_buy_swap_emits_fill_from_output_leg() {
10341 let amount_in = expected_buy_amount_in();
10342 let min_amount_out = expected_buy_min_amount_out(50);
10343 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10344 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10345 let Some((admin_pool, schema, mut client, state, cache)) =
10346 swap_client_with_buy_database("execution_submit_buy_fill_test", state).await
10347 else {
10348 return;
10349 };
10350 let order = test_market_buy_order(test_pool().instrument_id);
10351 cache
10352 .borrow_mut()
10353 .add_order(order.clone(), None, None, true)
10354 .unwrap();
10355 let mut receiver = start_with_events(&mut client);
10356 let plan = client
10357 .prepare_swap(&submit_order_cmd(&order), &order)
10358 .unwrap();
10359
10360 execute_swap(
10361 plan,
10362 client.transaction_executor().unwrap(),
10363 client.emitter.clone(),
10364 client.transaction_limits.max_quote_age_blocks,
10365 client.transaction_limits.deadline_seconds,
10366 )
10367 .await
10368 .unwrap();
10369
10370 let mut order_events = Vec::new();
10371
10372 while let Ok(event) = receiver.try_recv() {
10373 if let ExecutionEvent::Order(event) = event {
10374 order_events.push(event);
10375 }
10376 }
10377 assert_eq!(order_events.len(), 2, "was: {order_events:?}");
10378 assert!(matches!(&order_events[0], OrderEventAny::Submitted(_)));
10379 let OrderEventAny::Filled(fill) = &order_events[1] else {
10380 panic!("expected OrderFilled, was {:?}", order_events[1]);
10381 };
10382 let expected_commission = Money::from_u256(
10383 U256::from(50_112_u64) * U256::from(100_000_000_u64),
10384 test_pool().chain.native_currency(),
10385 )
10386 .unwrap();
10387 assert_eq!(fill.client_order_id, order.client_order_id());
10388 assert_eq!(fill.venue_order_id.as_str(), expected_hash.to_string());
10389 assert_eq!(fill.order_side, OrderSide::Buy);
10390 assert_eq!(fill.last_qty, Quantity::from("0.001"));
10391 assert_eq!(fill.currency.code.as_str(), "USDC");
10392 assert_eq!(fill.commission, Some(expected_commission));
10393 assert_eq!(fill.liquidity_side, LiquiditySide::Taker);
10394 assert_eq!(
10395 Money::from_decimal(
10396 fill.last_qty.as_decimal() * fill.last_px.as_decimal(),
10397 fill.currency,
10398 )
10399 .unwrap(),
10400 Money::from_u256(amount_in, fill.currency).unwrap()
10401 );
10402
10403 let (fill_emitted, active): (bool, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10404 "SELECT fill_emitted, active FROM {schema}.execution_intent"
10405 )))
10406 .fetch_one(&admin_pool)
10407 .await
10408 .unwrap();
10409 assert!(fill_emitted);
10410 assert!(!active);
10411
10412 let database = client.cache.database.as_ref().unwrap().clone();
10413 let payload_keys = client.payload_keys.clone();
10414 let restart_config = client.config.clone();
10415 drop(client);
10416 let (mut restarted, _) = swap_client_with_cache(restart_config);
10417 restarted.cache.database = Some(database);
10418 restarted.payload_keys = payload_keys;
10419 restarted.signer = Some(Arc::new(
10420 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
10421 ));
10422 let mut restart_receiver = start_with_events(&mut restarted);
10423 restarted.reconcile_unresolved_execution().await.unwrap();
10424 restarted.reconcile_unresolved_execution().await.unwrap();
10425 assert!(collect_order_events(&mut restart_receiver).is_empty());
10426 assert_eq!(
10427 state
10428 .recorded_requests()
10429 .iter()
10430 .filter(|request| request["method"] == "eth_sendRawTransaction")
10431 .count(),
10432 1
10433 );
10434
10435 drop_execution_schema(&admin_pool, &schema).await;
10436 }
10437
10438 #[tokio::test]
10439 async fn finalized_buy_swap_cancels_remainder_when_output_is_short() {
10440 let amount_in = expected_buy_amount_in();
10441 let min_amount_out = expected_buy_min_amount_out(50);
10442 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10443 let short_base_out = min_amount_out;
10444 let mut state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10445 state = state.with_response(
10446 "eth_getTransactionReceipt",
10447 &finalized_buy_swap_receipt_with_base_out(expected_hash, amount_in, short_base_out),
10448 );
10449 let Some((admin_pool, schema, mut client, _, cache)) =
10450 swap_client_with_buy_database("execution_submit_buy_short_fill_test", state).await
10451 else {
10452 return;
10453 };
10454 let order = test_market_buy_order(test_pool().instrument_id);
10455 cache
10456 .borrow_mut()
10457 .add_order(order.clone(), None, None, true)
10458 .unwrap();
10459 let mut receiver = start_with_events(&mut client);
10460 let plan = client
10461 .prepare_swap(&submit_order_cmd(&order), &order)
10462 .unwrap();
10463
10464 execute_swap(
10465 plan,
10466 client.transaction_executor().unwrap(),
10467 client.emitter.clone(),
10468 client.transaction_limits.max_quote_age_blocks,
10469 client.transaction_limits.deadline_seconds,
10470 )
10471 .await
10472 .unwrap();
10473
10474 let events = collect_order_events(&mut receiver);
10475 assert_eq!(events.len(), 3, "was: {events:?}");
10476 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
10477 let OrderEventAny::Filled(fill) = &events[1] else {
10478 panic!("expected OrderFilled, was {:?}", events[1]);
10479 };
10480 let expected_qty = raw_amount_to_quantity(short_base_out, 18).unwrap();
10481 assert_eq!(fill.order_side, OrderSide::Buy);
10482 assert_eq!(fill.last_qty, expected_qty);
10483 assert!(fill.last_qty < order.quantity());
10484 let OrderEventAny::Canceled(canceled) = &events[2] else {
10485 panic!("expected OrderCanceled, was {:?}", events[2]);
10486 };
10487 assert_eq!(canceled.client_order_id, order.client_order_id());
10488 assert_eq!(
10489 canceled.venue_order_id.as_ref().map(VenueOrderId::as_str),
10490 Some(expected_hash.to_string().as_str())
10491 );
10492
10493 drop_execution_schema(&admin_pool, &schema).await;
10494 }
10495
10496 #[tokio::test]
10497 async fn finalized_buy_swap_reports_full_output_when_above_order_quantity() {
10498 let amount_in = expected_buy_amount_in();
10499 let min_amount_out = expected_buy_min_amount_out(50);
10500 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10501 let overshoot_base_out = expected_buy_base_amount() * U256::from(2) + U256::from(44);
10502 let mut state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10503 state = state.with_response(
10504 "eth_getTransactionReceipt",
10505 &finalized_buy_swap_receipt_with_base_out(expected_hash, amount_in, overshoot_base_out),
10506 );
10507 let Some((admin_pool, schema, mut client, _, cache)) =
10508 swap_client_with_buy_database("execution_submit_buy_overshoot_fill_test", state).await
10509 else {
10510 return;
10511 };
10512 let order = test_market_buy_order(test_pool().instrument_id);
10513 cache
10514 .borrow_mut()
10515 .add_order(order.clone(), None, None, true)
10516 .unwrap();
10517 let mut receiver = start_with_events(&mut client);
10518 let plan = client
10519 .prepare_swap(&submit_order_cmd(&order), &order)
10520 .unwrap();
10521
10522 execute_swap(
10523 plan,
10524 client.transaction_executor().unwrap(),
10525 client.emitter.clone(),
10526 client.transaction_limits.max_quote_age_blocks,
10527 client.transaction_limits.deadline_seconds,
10528 )
10529 .await
10530 .unwrap();
10531
10532 let events = collect_order_events(&mut receiver);
10533 assert_eq!(events.len(), 2, "was: {events:?}");
10534 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
10535 let OrderEventAny::Filled(fill) = &events[1] else {
10536 panic!("expected OrderFilled, was {:?}", events[1]);
10537 };
10538 let expected_qty = raw_amount_to_quantity(overshoot_base_out, 18).unwrap();
10539 assert_eq!(fill.order_side, OrderSide::Buy);
10540 assert_eq!(fill.last_qty, expected_qty);
10541 assert!(fill.last_qty > order.quantity());
10542 assert_eq!(
10543 Money::from_decimal(
10544 fill.last_qty.as_decimal() * fill.last_px.as_decimal(),
10545 fill.currency,
10546 )
10547 .unwrap(),
10548 Money::from_u256(amount_in, fill.currency).unwrap()
10549 );
10550
10551 drop_execution_schema(&admin_pool, &schema).await;
10552 }
10553
10554 #[tokio::test]
10555 async fn finalized_buy_swap_quarantines_sell_oriented_log() {
10556 let amount_in = expected_buy_amount_in();
10557 let min_amount_out = expected_buy_min_amount_out(50);
10558 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10559 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in)
10560 .with_response(
10561 "eth_getTransactionReceipt",
10562 &finalized_swap_receipt(expected_hash),
10563 );
10564 let Some((admin_pool, schema, mut client, _, cache)) =
10565 swap_client_with_buy_database("execution_submit_buy_sell_log_test", state).await
10566 else {
10567 return;
10568 };
10569 let order = test_market_buy_order(test_pool().instrument_id);
10570 cache
10571 .borrow_mut()
10572 .add_order(order.clone(), None, None, true)
10573 .unwrap();
10574 let mut receiver = start_with_events(&mut client);
10575 let plan = client
10576 .prepare_swap(&submit_order_cmd(&order), &order)
10577 .unwrap();
10578
10579 let error = execute_swap(
10580 plan,
10581 client.transaction_executor().unwrap(),
10582 client.emitter.clone(),
10583 client.transaction_limits.max_quote_age_blocks,
10584 client.transaction_limits.deadline_seconds,
10585 )
10586 .await
10587 .unwrap_err();
10588
10589 let events = collect_order_events(&mut receiver);
10590 assert_swap_quarantined_without_terminal_event(&events);
10591 assert!(
10592 error
10593 .to_string()
10594 .contains("does not match the persisted amount")
10595 || error.to_string().contains("is not a BUY output"),
10596 "was: {error}"
10597 );
10598 assert!(client.in_flight.lock().is_some());
10599
10600 drop_execution_schema(&admin_pool, &schema).await;
10601 }
10602
10603 #[tokio::test]
10604 async fn submit_order_applies_buy_slippage_to_base_output() {
10605 let amount_in = expected_buy_amount_in();
10606 let min_amount_out = expected_buy_min_amount_out(200);
10607 let (expected_hash, expected_raw) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10608 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10609 let Some((admin_pool, schema, mut client, state, cache)) =
10610 swap_client_with_buy_database("execution_submit_buy_slippage_test", state).await
10611 else {
10612 return;
10613 };
10614 let order = test_market_buy_order(test_pool().instrument_id);
10615 cache
10616 .borrow_mut()
10617 .add_order(order.clone(), None, None, true)
10618 .unwrap();
10619 let mut cmd = submit_order_cmd(&order);
10620 cmd.params = Some(serde_json::from_str(r#"{"slippage_bps": 200}"#).unwrap());
10621 let mut receiver = start_with_events(&mut client);
10622
10623 client.submit_order(cmd).unwrap();
10624 await_pending_tasks(&client).await;
10625
10626 let events = collect_order_events(&mut receiver);
10627 assert_swap_submitted_and_filled(&events);
10628 let broadcasts: Vec<_> = state
10629 .recorded_requests()
10630 .into_iter()
10631 .filter(|request| request["method"] == "eth_sendRawTransaction")
10632 .collect();
10633 assert_eq!(broadcasts.len(), 1);
10634 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
10635
10636 drop_execution_schema(&admin_pool, &schema).await;
10637 }
10638
10639 #[tokio::test]
10640 async fn submit_order_denies_buy_on_insufficient_quote_balance() {
10641 let amount_in = expected_buy_amount_in();
10642 let min_amount_out = expected_buy_min_amount_out(50);
10643 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10644 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in)
10645 .with_call_response(BALANCE_OF_SELECTOR, CALL_ZERO);
10646 let Some((admin_pool, schema, mut client, _, cache)) =
10647 swap_client_with_buy_database("execution_submit_buy_balance_test", state).await
10648 else {
10649 return;
10650 };
10651 let order = test_market_buy_order(test_pool().instrument_id);
10652 cache
10653 .borrow_mut()
10654 .add_order(order.clone(), None, None, true)
10655 .unwrap();
10656 let mut receiver = start_with_events(&mut client);
10657
10658 client.submit_order(submit_order_cmd(&order)).unwrap();
10659 await_pending_tasks(&client).await;
10660
10661 let events = collect_order_events(&mut receiver);
10662 assert_eq!(events.len(), 1);
10663 let OrderEventAny::Denied(denied) = &events[0] else {
10664 panic!("expected OrderDenied, was {:?}", events[0]);
10665 };
10666 assert!(
10667 denied.reason.as_str().contains("is below the swap amount"),
10668 "was: {}",
10669 denied.reason
10670 );
10671
10672 drop_execution_schema(&admin_pool, &schema).await;
10673 }
10674
10675 #[tokio::test]
10676 async fn finalized_swap_without_log_stays_quarantined() {
10677 let state = swap_rpc_state()
10678 .await
10679 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS);
10680 let Some((admin_pool, schema, mut client, _, _)) =
10681 swap_client_with_database("execution_submit_missing_log_test", state).await
10682 else {
10683 return;
10684 };
10685 let order = test_market_sell_order(test_pool().instrument_id);
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 let (status, terminal_emitted, active): (String, bool, bool) =
10693 sqlx::query_as(sqlx::AssertSqlSafe(format!(
10694 "SELECT status, terminal_emitted, active FROM {schema}.execution_intent"
10695 )))
10696 .fetch_one(&admin_pool)
10697 .await
10698 .unwrap();
10699
10700 assert_swap_quarantined_without_terminal_event(&events);
10701 assert_eq!(status, "broadcast");
10702 assert!(!terminal_emitted);
10703 assert!(active);
10704 assert!(client.in_flight.lock().is_some());
10705
10706 drop_execution_schema(&admin_pool, &schema).await;
10707 }
10708
10709 #[tokio::test]
10710 async fn finalized_swap_refresh_failure_stays_owned_for_reconciliation() {
10711 let min_amount_out = expected_min_amount_out(50);
10712 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
10713 let state = finalized_swap_rpc_state(expected_hash, min_amount_out).with_response_sequence(
10714 "eth_getBalance",
10715 &[
10716 GET_BALANCE,
10717 GET_BALANCE,
10718 GET_BALANCE,
10719 RPC_METHOD_NOT_FOUND,
10720 RPC_METHOD_NOT_FOUND,
10721 RPC_METHOD_NOT_FOUND,
10722 ],
10723 );
10724 let Some((admin_pool, schema, mut client, _state, _)) =
10725 swap_client_with_database("execution_submit_refresh_fail_test", state).await
10726 else {
10727 return;
10728 };
10729 let order = test_market_sell_order(test_pool().instrument_id);
10730 let mut receiver = start_with_events(&mut client);
10731 let plan = client
10732 .prepare_swap(&submit_order_cmd(&order), &order)
10733 .unwrap();
10734
10735 let error = execute_swap(
10736 plan,
10737 client.transaction_executor().unwrap(),
10738 client.emitter.clone(),
10739 client.transaction_limits.max_quote_age_blocks,
10740 client.transaction_limits.deadline_seconds,
10741 )
10742 .await
10743 .unwrap_err();
10744 let events = collect_order_events(&mut receiver);
10745 let (status, fill_emitted, active): (String, bool, bool) =
10746 sqlx::query_as(sqlx::AssertSqlSafe(format!(
10747 "SELECT status, fill_emitted, active FROM {schema}.execution_intent"
10748 )))
10749 .fetch_one(&admin_pool)
10750 .await
10751 .unwrap();
10752
10753 assert!(
10754 error
10755 .to_string()
10756 .contains("finalized native balance verification is locally invalid"),
10757 "was: {error}"
10758 );
10759 assert_eq!(events.len(), 1, "was: {events:?}");
10760 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
10761 assert_eq!(status, "broadcast");
10762 assert!(!fill_emitted);
10763 assert!(active);
10764 assert!(client.in_flight.lock().is_some());
10765
10766 drop_execution_schema(&admin_pool, &schema).await;
10767 }
10768
10769 #[tokio::test]
10770 async fn submit_order_denies_router_factory_mismatch_before_signing() {
10771 let state = swap_rpc_state()
10772 .await
10773 .with_call_response(FACTORY_SELECTOR, CALL_ZERO);
10774 let Some((admin_pool, schema, mut client, state, _)) =
10775 swap_client_with_database("execution_submit_factory_test", state).await
10776 else {
10777 return;
10778 };
10779 let order = test_market_sell_order(test_pool().instrument_id);
10780 let mut receiver = start_with_events(&mut client);
10781
10782 client.submit_order(submit_order_cmd(&order)).unwrap();
10783 await_pending_tasks(&client).await;
10784
10785 let events = collect_order_events(&mut receiver);
10786 assert_eq!(events.len(), 1);
10787 let OrderEventAny::Denied(denied) = &events[0] else {
10788 panic!("expected OrderDenied, was {:?}", events[0]);
10789 };
10790 assert!(
10791 denied
10792 .reason
10793 .as_str()
10794 .contains("swap deployment manifest verification disagreed"),
10795 "was: {}",
10796 denied.reason
10797 );
10798 let requests = state.recorded_requests();
10799 assert!(
10800 requests
10801 .iter()
10802 .all(|request| request["method"] != "eth_getTransactionCount")
10803 );
10804 assert!(
10805 requests
10806 .iter()
10807 .all(|request| request["method"] != "eth_sendRawTransaction")
10808 );
10809
10810 drop_execution_schema(&admin_pool, &schema).await;
10811 }
10812
10813 #[tokio::test]
10814 async fn submit_order_denies_router_weth_mismatch_before_signing() {
10815 let state = swap_rpc_state()
10816 .await
10817 .with_call_response(WETH9_SELECTOR, CALL_ZERO);
10818 let Some((admin_pool, schema, mut client, state, _)) =
10819 swap_client_with_database("execution_submit_weth_test", state).await
10820 else {
10821 return;
10822 };
10823 let order = test_market_sell_order(test_pool().instrument_id);
10824 let mut receiver = start_with_events(&mut client);
10825
10826 client.submit_order(submit_order_cmd(&order)).unwrap();
10827 await_pending_tasks(&client).await;
10828
10829 let events = collect_order_events(&mut receiver);
10830 assert_eq!(events.len(), 1);
10831 let OrderEventAny::Denied(denied) = &events[0] else {
10832 panic!("expected OrderDenied, was {:?}", events[0]);
10833 };
10834 assert!(
10835 denied
10836 .reason
10837 .as_str()
10838 .contains("swap deployment manifest verification disagreed"),
10839 "was: {}",
10840 denied.reason
10841 );
10842 let requests = state.recorded_requests();
10843 assert!(
10844 requests
10845 .iter()
10846 .all(|request| request["method"] != "eth_getTransactionCount")
10847 );
10848 assert!(
10849 requests
10850 .iter()
10851 .all(|request| request["method"] != "eth_sendRawTransaction")
10852 );
10853
10854 drop_execution_schema(&admin_pool, &schema).await;
10855 }
10856
10857 #[tokio::test]
10858 async fn submit_order_denies_factory_pool_mismatch_before_signing() {
10859 let state = swap_rpc_state()
10860 .await
10861 .with_call_response(GET_POOL_SELECTOR, CALL_ZERO);
10862 let Some((admin_pool, schema, mut client, state, _)) =
10863 swap_client_with_database("execution_submit_pool_identity_test", state).await
10864 else {
10865 return;
10866 };
10867 let order = test_market_sell_order(test_pool().instrument_id);
10868 let mut receiver = start_with_events(&mut client);
10869
10870 client.submit_order(submit_order_cmd(&order)).unwrap();
10871 await_pending_tasks(&client).await;
10872
10873 let events = collect_order_events(&mut receiver);
10874 assert_eq!(events.len(), 1);
10875 let OrderEventAny::Denied(denied) = &events[0] else {
10876 panic!("expected OrderDenied, was {:?}", events[0]);
10877 };
10878 assert!(
10879 denied
10880 .reason
10881 .as_str()
10882 .contains("swap deployment manifest verification disagreed"),
10883 "was: {}",
10884 denied.reason
10885 );
10886 let requests = state.recorded_requests();
10887 assert!(
10888 requests
10889 .iter()
10890 .all(|request| request["method"] != "eth_getTransactionCount")
10891 );
10892 assert!(
10893 requests
10894 .iter()
10895 .all(|request| request["method"] != "eth_sendRawTransaction")
10896 );
10897
10898 drop_execution_schema(&admin_pool, &schema).await;
10899 }
10900
10901 #[tokio::test]
10902 async fn submit_order_denies_cached_token_decimal_mismatch_before_signing() {
10903 let state = swap_rpc_state().await.with_contract_call_response(
10904 WETH,
10905 DECIMALS_SELECTOR,
10906 CALL_DECIMALS_6,
10907 );
10908 let Some((admin_pool, schema, mut client, state, _)) =
10909 swap_client_with_database("execution_submit_decimals_test", state).await
10910 else {
10911 return;
10912 };
10913 let order = test_market_sell_order(test_pool().instrument_id);
10914 let mut receiver = start_with_events(&mut client);
10915
10916 client.submit_order(submit_order_cmd(&order)).unwrap();
10917 await_pending_tasks(&client).await;
10918
10919 let events = collect_order_events(&mut receiver);
10920 assert_eq!(events.len(), 1);
10921 let OrderEventAny::Denied(denied) = &events[0] else {
10922 panic!("expected OrderDenied, was {:?}", events[0]);
10923 };
10924 assert!(
10925 denied
10926 .reason
10927 .as_str()
10928 .contains("swap deployment manifest verification disagreed"),
10929 "was: {}",
10930 denied.reason
10931 );
10932 let requests = state.recorded_requests();
10933 assert!(
10934 requests
10935 .iter()
10936 .all(|request| request["method"] != "eth_getTransactionCount")
10937 );
10938 assert!(
10939 requests
10940 .iter()
10941 .all(|request| request["method"] != "eth_sendRawTransaction")
10942 );
10943
10944 drop_execution_schema(&admin_pool, &schema).await;
10945 }
10946
10947 #[tokio::test]
10948 async fn submit_order_denies_on_insufficient_router_allowance() {
10949 let state = swap_rpc_state()
10950 .await
10951 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO);
10952 let Some((admin_pool, schema, mut client, state, _)) =
10953 swap_client_with_database("execution_submit_allowance_test", state).await
10954 else {
10955 return;
10956 };
10957 let order = test_market_sell_order(test_pool().instrument_id);
10958 let mut receiver = start_with_events(&mut client);
10959
10960 client.submit_order(submit_order_cmd(&order)).unwrap();
10961 await_pending_tasks(&client).await;
10962
10963 let events = collect_order_events(&mut receiver);
10964 assert_eq!(events.len(), 1);
10965 let OrderEventAny::Denied(denied) = &events[0] else {
10966 panic!("expected OrderDenied, was {:?}", events[0]);
10967 };
10968 assert!(
10969 denied.reason.as_str().contains("below the swap amount"),
10970 "was: {}",
10971 denied.reason
10972 );
10973 let requests = state.recorded_requests();
10974 assert!(
10975 requests
10976 .iter()
10977 .all(|request| request["method"] != "eth_sendRawTransaction"),
10978 "no broadcast may follow a pre-trade denial"
10979 );
10980 let row_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
10981 "SELECT COUNT(*) FROM {schema}.execution_intent"
10982 )))
10983 .fetch_one(&admin_pool)
10984 .await
10985 .unwrap();
10986 assert_eq!(row_count, 0);
10987 assert!(client.in_flight.lock().is_none());
10988
10989 drop_execution_schema(&admin_pool, &schema).await;
10990 }
10991
10992 #[tokio::test]
10993 async fn submit_order_denies_on_insufficient_input_balance() {
10994 let state = swap_rpc_state()
10995 .await
10996 .with_call_response(BALANCE_OF_SELECTOR, CALL_ZERO);
10997 let Some((admin_pool, schema, mut client, _, _)) =
10998 swap_client_with_database("execution_submit_balance_test", state).await
10999 else {
11000 return;
11001 };
11002 let order = test_market_sell_order(test_pool().instrument_id);
11003 let mut receiver = start_with_events(&mut client);
11004
11005 client.submit_order(submit_order_cmd(&order)).unwrap();
11006 await_pending_tasks(&client).await;
11007
11008 let events = collect_order_events(&mut receiver);
11009 assert_eq!(events.len(), 1);
11010 let OrderEventAny::Denied(denied) = &events[0] else {
11011 panic!("expected OrderDenied, was {:?}", events[0]);
11012 };
11013 assert!(
11014 denied.reason.as_str().contains("is below the swap amount"),
11015 "was: {}",
11016 denied.reason
11017 );
11018
11019 drop_execution_schema(&admin_pool, &schema).await;
11020 }
11021
11022 #[tokio::test]
11023 async fn submit_order_denies_on_insufficient_native_balance() {
11024 let state = swap_rpc_state()
11025 .await
11026 .with_response("eth_getBalance", GET_BALANCE_INSUFFICIENT);
11027 let Some((admin_pool, schema, mut client, state, _)) =
11028 swap_client_with_database("execution_submit_native_balance_test", state).await
11029 else {
11030 return;
11031 };
11032 let order = test_market_sell_order(test_pool().instrument_id);
11033 let mut receiver = start_with_events(&mut client);
11034
11035 client.submit_order(submit_order_cmd(&order)).unwrap();
11036 await_pending_tasks(&client).await;
11037
11038 let events = collect_order_events(&mut receiver);
11039 assert_eq!(events.len(), 1);
11040 let OrderEventAny::Denied(denied) = &events[0] else {
11041 panic!("expected OrderDenied, was {:?}", events[0]);
11042 };
11043 assert!(
11044 denied
11045 .reason
11046 .as_str()
11047 .contains("below maximum transaction cost"),
11048 "was: {}",
11049 denied.reason
11050 );
11051 assert!(
11052 state
11053 .recorded_requests()
11054 .iter()
11055 .all(|request| request["method"] != "eth_sendRawTransaction"),
11056 "no broadcast may follow an insufficient native balance"
11057 );
11058 let (row_count, status): (i64, Option<String>) = sqlx::query_as(sqlx::AssertSqlSafe(
11059 format!("SELECT COUNT(*), MAX(status) FROM {schema}.execution_intent"),
11060 ))
11061 .fetch_one(&admin_pool)
11062 .await
11063 .unwrap();
11064 assert_eq!(row_count, 1);
11065 assert_eq!(status.as_deref(), Some("recoverable"));
11066 assert!(client.in_flight.lock().is_none());
11067
11068 drop_execution_schema(&admin_pool, &schema).await;
11069 }
11070
11071 #[tokio::test]
11072 async fn submit_order_denies_on_stale_quote() {
11073 let Some((admin_pool, schema, mut client, state, cache)) =
11074 swap_client_with_database("execution_submit_stale_quote_test", swap_rpc_state().await)
11075 .await
11076 else {
11077 return;
11078 };
11079 let pool = test_pool();
11080 cache
11081 .borrow_mut()
11082 .add_pool_profiler(test_profiler(&pool, FIXTURE_BLOCK - 101))
11083 .unwrap();
11084 let order = test_market_sell_order(pool.instrument_id);
11085 let mut receiver = start_with_events(&mut client);
11086
11087 client.submit_order(submit_order_cmd(&order)).unwrap();
11088 await_pending_tasks(&client).await;
11089
11090 let events = collect_order_events(&mut receiver);
11091 assert_eq!(events.len(), 1);
11092 let OrderEventAny::Denied(denied) = &events[0] else {
11093 panic!("expected OrderDenied, was {:?}", events[0]);
11094 };
11095 assert!(
11096 denied.reason.as_str().contains("Stale quote"),
11097 "was: {}",
11098 denied.reason
11099 );
11100 let requests = state.recorded_requests();
11101 assert!(
11102 requests
11103 .iter()
11104 .all(|request| request["method"] != "eth_sendRawTransaction"),
11105 "no broadcast may follow a pre-trade denial"
11106 );
11107
11108 drop_execution_schema(&admin_pool, &schema).await;
11109 }
11110
11111 #[tokio::test]
11112 async fn submit_order_denies_quote_ahead_of_chain_head() {
11113 let Some((admin_pool, schema, mut client, state, cache)) =
11114 swap_client_with_database("execution_submit_ahead_quote_test", swap_rpc_state().await)
11115 .await
11116 else {
11117 return;
11118 };
11119 let pool = test_pool();
11120 cache
11121 .borrow_mut()
11122 .add_pool_profiler(test_profiler(&pool, FIXTURE_BLOCK + 1))
11123 .unwrap();
11124 let order = test_market_sell_order(pool.instrument_id);
11125 let mut receiver = start_with_events(&mut client);
11126
11127 client.submit_order(submit_order_cmd(&order)).unwrap();
11128 await_pending_tasks(&client).await;
11129
11130 let events = collect_order_events(&mut receiver);
11131 assert_eq!(events.len(), 1);
11132 let OrderEventAny::Denied(denied) = &events[0] else {
11133 panic!("expected OrderDenied, was {:?}", events[0]);
11134 };
11135 assert!(
11136 denied
11137 .reason
11138 .as_str()
11139 .contains("is ahead of the latest block"),
11140 "was: {}",
11141 denied.reason
11142 );
11143 let requests = state.recorded_requests();
11144 assert!(
11145 requests
11146 .iter()
11147 .all(|request| request["method"] != "eth_sendRawTransaction"),
11148 "no broadcast may follow a pre-trade denial"
11149 );
11150
11151 drop_execution_schema(&admin_pool, &schema).await;
11152 }
11153
11154 #[tokio::test]
11155 async fn submit_order_denies_on_deadline_overflow() {
11156 let Some((admin_pool, schema, mut client, state, _)) = swap_client_with_database(
11157 "execution_submit_deadline_overflow_test",
11158 swap_rpc_state().await,
11159 )
11160 .await
11161 else {
11162 return;
11163 };
11164 client.transaction_limits.deadline_seconds = u64::MAX;
11165 let order = test_market_sell_order(test_pool().instrument_id);
11166 let mut receiver = start_with_events(&mut client);
11167
11168 client.submit_order(submit_order_cmd(&order)).unwrap();
11169 await_pending_tasks(&client).await;
11170
11171 let events = collect_order_events(&mut receiver);
11172 assert_eq!(events.len(), 1);
11173 let OrderEventAny::Denied(denied) = &events[0] else {
11174 panic!("expected OrderDenied, was {:?}", events[0]);
11175 };
11176 assert!(
11177 denied.reason.as_str().contains("deadline overflow"),
11178 "was: {}",
11179 denied.reason
11180 );
11181 let requests = state.recorded_requests();
11182 assert!(
11183 requests
11184 .iter()
11185 .all(|request| request["method"] != "eth_sendRawTransaction"),
11186 "no broadcast may follow a pre-trade denial"
11187 );
11188 assert!(client.in_flight.lock().is_none());
11189
11190 drop_execution_schema(&admin_pool, &schema).await;
11191 }
11192
11193 #[tokio::test]
11194 async fn submit_order_reconciles_node_rejection_to_finalized_receipt() {
11195 let state = swap_rpc_state()
11196 .await
11197 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_REJECTED);
11198 let Some((admin_pool, schema, mut client, _state, _)) =
11199 swap_client_with_database("execution_submit_node_rejected_test", state).await
11200 else {
11201 return;
11202 };
11203 let order = test_market_sell_order(test_pool().instrument_id);
11204 let mut receiver = start_with_events(&mut client);
11205
11206 client.submit_order(submit_order_cmd(&order)).unwrap();
11207 await_pending_tasks(&client).await;
11208
11209 let events = collect_order_events(&mut receiver);
11210 assert_swap_submitted_and_filled(&events);
11211 let (purpose, status, client_order_id): (String, String, Option<String>) =
11212 sqlx::query_as(sqlx::AssertSqlSafe(format!(
11213 "SELECT purpose, status, client_order_id FROM {schema}.execution_intent"
11214 )))
11215 .fetch_one(&admin_pool)
11216 .await
11217 .unwrap();
11218 assert_eq!(purpose, "swap");
11219 assert_eq!(status, "finalized");
11220 assert_eq!(
11221 client_order_id.as_deref(),
11222 Some(order.client_order_id().as_str())
11223 );
11224 assert!(client.in_flight.lock().is_none());
11225
11226 drop_execution_schema(&admin_pool, &schema).await;
11227 }
11228
11229 #[tokio::test]
11230 async fn submit_order_acknowledges_uncertain_nonce_too_low() {
11231 let state = swap_rpc_state()
11232 .await
11233 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_NONCE_TOO_LOW);
11234 let Some((admin_pool, schema, mut client, _state, _)) =
11235 swap_client_with_database("execution_submit_nonce_too_low_test", state).await
11236 else {
11237 return;
11238 };
11239 let order = test_market_sell_order(test_pool().instrument_id);
11240 let mut receiver = start_with_events(&mut client);
11241
11242 client.submit_order(submit_order_cmd(&order)).unwrap();
11243 await_pending_tasks(&client).await;
11244
11245 let events = collect_order_events(&mut receiver);
11246 assert_swap_submitted_and_filled(&events);
11247 let (purpose, status, client_order_id): (String, String, Option<String>) =
11248 sqlx::query_as(sqlx::AssertSqlSafe(format!(
11249 "SELECT purpose, status, client_order_id FROM {schema}.execution_intent"
11250 )))
11251 .fetch_one(&admin_pool)
11252 .await
11253 .unwrap();
11254 assert_eq!(purpose, "swap");
11255 assert_eq!(status, "finalized");
11256 assert_eq!(
11257 client_order_id.as_deref(),
11258 Some(order.client_order_id().as_str())
11259 );
11260 assert!(client.in_flight.lock().is_none());
11261
11262 drop_execution_schema(&admin_pool, &schema).await;
11263 }
11264
11265 #[tokio::test]
11266 async fn submit_order_rejected_on_reverted_receipt() {
11267 let expected_min_out = expected_min_amount_out(50);
11268 let (expected_hash, _) = expected_swap_tx(expected_min_out).await;
11269 let block = finalized_swap_block(expected_hash, expected_min_out);
11270 let receipt = receipt_with_transaction_hash(RECEIPT_REVERTED, expected_hash);
11271 let state = with_finalized_identity(
11272 swap_rpc_state()
11273 .await
11274 .with_response("eth_getTransactionReceipt", &receipt),
11275 &block,
11276 &receipt,
11277 );
11278 let Some((admin_pool, schema, mut client, _state, _)) =
11279 swap_client_with_database("execution_submit_reverted_test", state).await
11280 else {
11281 return;
11282 };
11283 let order = test_market_sell_order(test_pool().instrument_id);
11284 let mut receiver = start_with_events(&mut client);
11285 client.submit_order(submit_order_cmd(&order)).unwrap();
11286 await_pending_tasks(&client).await;
11287
11288 let events = collect_order_events(&mut receiver);
11289 assert_eq!(events.len(), 2);
11290 assert!(
11291 matches!(&events[0], OrderEventAny::Submitted(_)),
11292 "was: {:?}",
11293 events[0]
11294 );
11295 let OrderEventAny::Rejected(rejected) = &events[1] else {
11296 panic!("expected OrderRejected, was {:?}", events[1]);
11297 };
11298 assert!(
11299 rejected.reason.as_str().contains("reverted on-chain"),
11300 "was: {}",
11301 rejected.reason
11302 );
11303
11304 let record = client
11305 .cache
11306 .get_execution_transaction(42161, &expected_hash.to_string())
11307 .await
11308 .unwrap()
11309 .unwrap();
11310 assert_eq!(record.purpose, "swap");
11311 assert_eq!(record.status, "reverted");
11312 assert_eq!(
11313 record.client_order_id.as_deref(),
11314 Some(order.client_order_id().as_str())
11315 );
11316 assert!(client.in_flight.lock().is_none());
11317
11318 drop_execution_schema(&admin_pool, &schema).await;
11319 }
11320
11321 #[tokio::test]
11322 async fn submit_order_acknowledges_ambiguous_broadcast() {
11323 let state = swap_rpc_state()
11326 .await
11327 .with_response("eth_sendRawTransaction", "not json");
11328 let Some((admin_pool, schema, mut client, _state, _)) =
11329 swap_client_with_database("execution_submit_ambiguous_test", state).await
11330 else {
11331 return;
11332 };
11333 let order = test_market_sell_order(test_pool().instrument_id);
11334 let mut receiver = start_with_events(&mut client);
11335
11336 client.submit_order(submit_order_cmd(&order)).unwrap();
11337 await_pending_tasks(&client).await;
11338
11339 let events = collect_order_events(&mut receiver);
11340 assert_swap_submitted_and_filled(&events);
11341
11342 let record = client
11343 .cache
11344 .get_execution_transaction(
11345 42161,
11346 &expected_swap_tx(expected_min_amount_out(50))
11347 .await
11348 .0
11349 .to_string(),
11350 )
11351 .await
11352 .unwrap()
11353 .unwrap();
11354 assert_eq!(record.purpose, "swap");
11355 assert_eq!(record.status, "finalized");
11356 assert!(client.in_flight.lock().is_none());
11357
11358 drop_execution_schema(&admin_pool, &schema).await;
11359 }
11360
11361 #[tokio::test]
11362 async fn submit_order_acknowledges_broadcast_hash_mismatch() {
11363 let state = swap_rpc_state_for_mismatch().await;
11366 let Some((admin_pool, schema, mut client, _state, _)) =
11367 swap_client_with_database("execution_submit_hash_mismatch_test", state).await
11368 else {
11369 return;
11370 };
11371 let order = test_market_sell_order(test_pool().instrument_id);
11372 let mut receiver = start_with_events(&mut client);
11373
11374 client.submit_order(submit_order_cmd(&order)).unwrap();
11375 await_pending_tasks(&client).await;
11376
11377 let events = collect_order_events(&mut receiver);
11378 assert_swap_submitted_and_filled(&events);
11379
11380 let record = client
11381 .cache
11382 .get_execution_transaction(
11383 42161,
11384 &expected_swap_tx(expected_min_amount_out(50))
11385 .await
11386 .0
11387 .to_string(),
11388 )
11389 .await
11390 .unwrap()
11391 .unwrap();
11392 assert_eq!(record.purpose, "swap");
11393 assert_eq!(record.status, "finalized");
11394 assert!(client.in_flight.lock().is_none());
11395
11396 drop_execution_schema(&admin_pool, &schema).await;
11397 }
11398
11399 #[tokio::test]
11400 async fn submit_order_persists_authenticated_envelope_before_broadcast() {
11401 let state = swap_rpc_state().await;
11402 let Some((admin_pool, schema, mut client, state, _)) =
11403 swap_client_with_database("protected_submit_test", state).await
11404 else {
11405 return;
11406 };
11407 let order = test_market_sell_order(test_pool().instrument_id);
11408 let mut receiver = start_with_events(&mut client);
11409
11410 client.submit_order(submit_order_cmd(&order)).unwrap();
11411 await_pending_tasks(&client).await;
11412
11413 let events = collect_order_events(&mut receiver);
11414 assert_swap_submitted_and_filled(&events);
11415 let representations: Vec<(bool, bool)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
11416 "SELECT raw_transaction IS NULL, sealed_transaction IS NOT NULL \
11417 FROM {schema}.execution_transaction_hash WHERE payload_expected"
11418 )))
11419 .fetch_all(&admin_pool)
11420 .await
11421 .unwrap();
11422 let broadcasts = state
11423 .recorded_requests()
11424 .into_iter()
11425 .filter(|request| request["method"] == "eth_sendRawTransaction")
11426 .count();
11427 assert_eq!(representations, vec![(true, true)]);
11428 assert_eq!(broadcasts, 1);
11429
11430 drop_execution_schema(&admin_pool, &schema).await;
11431 }
11432
11433 #[tokio::test]
11434 async fn protected_persistence_failure_prevents_broadcast_and_acknowledgment() {
11435 let state = swap_rpc_state().await;
11436 let Some((admin_pool, schema, mut client, state, _)) =
11437 swap_client_with_database("protected_persist_failure_test", state).await
11438 else {
11439 return;
11440 };
11441
11442 for statement in [
11443 format!(
11444 "CREATE FUNCTION {schema}.reject_protected_payload() RETURNS trigger \
11445 LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''test payload rejection''; END'"
11446 ),
11447 format!(
11448 "CREATE TRIGGER reject_protected_payload BEFORE INSERT ON \
11449 {schema}.execution_transaction_hash FOR EACH ROW \
11450 EXECUTE FUNCTION {schema}.reject_protected_payload()"
11451 ),
11452 ] {
11453 sqlx::query(sqlx::AssertSqlSafe(statement))
11454 .execute(&admin_pool)
11455 .await
11456 .unwrap();
11457 }
11458 let order = test_market_sell_order(test_pool().instrument_id);
11459 let mut receiver = start_with_events(&mut client);
11460
11461 client.submit_order(submit_order_cmd(&order)).unwrap();
11462 await_pending_tasks(&client).await;
11463
11464 let events = collect_order_events(&mut receiver);
11465 assert!(
11466 events
11467 .iter()
11468 .all(|event| !matches!(event, OrderEventAny::Submitted(_)))
11469 );
11470 let broadcasts = state
11471 .recorded_requests()
11472 .into_iter()
11473 .filter(|request| request["method"] == "eth_sendRawTransaction")
11474 .count();
11475 let payload_rows: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11476 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
11477 )))
11478 .fetch_one(&admin_pool)
11479 .await
11480 .unwrap();
11481 assert_eq!(broadcasts, 0);
11482 assert_eq!(payload_rows, 0);
11483
11484 drop_execution_schema(&admin_pool, &schema).await;
11485 }
11486
11487 #[tokio::test]
11488 async fn submit_order_reservation_failure_denies_without_broadcast_and_releases_slot() {
11489 let Some((admin_pool, schema, mut client, state, cache)) =
11490 swap_client_with_database("execution_submit_persist_fail_test", swap_rpc_state().await)
11491 .await
11492 else {
11493 return;
11494 };
11495 sqlx::query(sqlx::AssertSqlSafe(format!(
11496 "DROP TABLE {schema}.execution_intent CASCADE"
11497 )))
11498 .execute(&admin_pool)
11499 .await
11500 .unwrap();
11501 let pool = test_pool();
11502 let first = test_market_sell_order(pool.instrument_id);
11503 let second = market_sell_order_with_id(pool.instrument_id, "O-SWAP-002");
11504 cache
11505 .borrow_mut()
11506 .add_order(second.clone(), None, None, false)
11507 .unwrap();
11508 let mut receiver = start_with_events(&mut client);
11509
11510 client.submit_order(submit_order_cmd(&first)).unwrap();
11511 await_pending_tasks(&client).await;
11512
11513 let events = collect_order_events(&mut receiver);
11514 assert_eq!(events.len(), 1);
11515 let OrderEventAny::Denied(denied) = &events[0] else {
11516 panic!("expected OrderDenied, was {:?}", events[0]);
11517 };
11518 assert!(
11519 denied
11520 .reason
11521 .as_str()
11522 .contains("Execution intent reservation failed before commit"),
11523 "was: {}",
11524 denied.reason
11525 );
11526 client.submit_order(submit_order_cmd(&second)).unwrap();
11527 await_pending_tasks(&client).await;
11528 let retry_events = collect_order_events(&mut receiver);
11529 assert_eq!(retry_events.len(), 1);
11530 let OrderEventAny::Denied(retry_denied) = &retry_events[0] else {
11531 panic!("expected OrderDenied, was {:?}", retry_events[0]);
11532 };
11533 assert_eq!(
11534 retry_denied.reason.as_str(),
11535 "Execution intent reservation failed before commit"
11536 );
11537 let broadcasts = state
11538 .recorded_requests()
11539 .into_iter()
11540 .filter(|request| request["method"] == "eth_sendRawTransaction")
11541 .count();
11542 assert_eq!(broadcasts, 0);
11543 assert!(client.in_flight.lock().is_none());
11544
11545 drop_execution_schema(&admin_pool, &schema).await;
11546 }
11547
11548 #[tokio::test]
11549 async fn submit_order_reservation_commit_failure_keeps_preparing_slot() {
11550 let Some((admin_pool, schema, mut client, state, cache)) = swap_client_with_database(
11551 "execution_submit_reservation_commit_fail_test",
11552 swap_rpc_state().await,
11553 )
11554 .await
11555 else {
11556 return;
11557 };
11558 install_reservation_commit_rejection(&admin_pool, &schema).await;
11559 let pool = test_pool();
11560 let first = test_market_sell_order(pool.instrument_id);
11561 let second = market_sell_order_with_id(pool.instrument_id, "O-SWAP-002");
11562 cache
11563 .borrow_mut()
11564 .add_order(second.clone(), None, None, false)
11565 .unwrap();
11566 let mut receiver = start_with_events(&mut client);
11567
11568 client.submit_order(submit_order_cmd(&first)).unwrap();
11569 await_pending_tasks(&client).await;
11570 client.submit_order(submit_order_cmd(&second)).unwrap();
11571 await_pending_tasks(&client).await;
11572
11573 let events = collect_order_events(&mut receiver);
11574 let submitted = events
11575 .iter()
11576 .filter(|event| matches!(event, OrderEventAny::Submitted(_)))
11577 .count();
11578 let denied_commit = events
11579 .iter()
11580 .filter(|event| {
11581 matches!(event, OrderEventAny::Denied(denied) if denied.reason.as_str() == "Execution intent reservation commit outcome is unknown; reconciliation is required")
11582 })
11583 .count();
11584 let denied_in_flight = events
11585 .iter()
11586 .filter(|event| {
11587 matches!(event, OrderEventAny::Denied(denied) if denied.reason.as_str().contains("at most one transaction can be in flight"))
11588 })
11589 .count();
11590 let requests = state.recorded_requests();
11591 let intent_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11592 "SELECT COUNT(*) FROM {schema}.execution_intent"
11593 )))
11594 .fetch_one(&admin_pool)
11595 .await
11596 .unwrap();
11597 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11598 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
11599 )))
11600 .fetch_one(&admin_pool)
11601 .await
11602 .unwrap();
11603
11604 assert_eq!(submitted, 0, "was: {events:?}");
11605 assert_eq!(denied_commit, 1, "was: {events:?}");
11606 assert_eq!(denied_in_flight, 1, "was: {events:?}");
11607 assert!(matches!(
11608 *client.in_flight.lock(),
11609 Some(InFlightSlot::Preparing(TransactionPurpose::Swap))
11610 ));
11611 assert_eq!(intent_count, 0);
11612 assert_eq!(signed_count, 0);
11613 assert!(
11614 requests
11615 .iter()
11616 .all(|request| request["method"] != "eth_getTransactionCount")
11617 );
11618 assert!(
11619 requests
11620 .iter()
11621 .all(|request| request["method"] != "eth_sendRawTransaction")
11622 );
11623
11624 drop_execution_schema(&admin_pool, &schema).await;
11625 }
11626
11627 #[tokio::test]
11628 async fn protected_payload_migration_failure_keeps_plaintext_and_blocks_ready() {
11629 let Some((admin_pool, schema, client, _)) = execution_client_with_unprotected_database(
11630 "protected_payload_migration_failure",
11631 execution_rpc_state(),
11632 )
11633 .await
11634 else {
11635 return;
11636 };
11637 let database = client.cache.database.as_ref().unwrap();
11638 let (intent, _) = persist_invalid_test_swap(database, None).await;
11639 let keys = payload_test_keys([0x31; 32], vec![], "migration-failure");
11640 let error = database
11641 .ensure_execution_payload_storage(&keys)
11642 .await
11643 .unwrap_err();
11644
11645 assert!(
11646 error
11647 .to_string()
11648 .contains("failed to authenticate execution payload")
11649 );
11650 let row = database
11651 .get_execution_transaction_hashes(intent.id)
11652 .await
11653 .unwrap()
11654 .pop()
11655 .unwrap();
11656 let operation: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11657 "SELECT operation FROM {schema}.execution_payload_state"
11658 )))
11659 .fetch_one(&admin_pool)
11660 .await
11661 .unwrap();
11662 assert_eq!(operation, "migrate");
11663 assert!(row.raw_transaction.is_some());
11664 assert!(row.sealed_transaction.is_none());
11665
11666 drop_execution_schema(&admin_pool, &schema).await;
11667 }
11668
11669 #[tokio::test]
11670 async fn protected_payload_migration_restart_restore_rewrap_and_rollback() {
11671 let Some((admin_pool, pg_config)) =
11672 connect_test_postgres("protected payload lifecycle").await
11673 else {
11674 return;
11675 };
11676 let schema = format!("protected_payload_lifecycle_{}", std::process::id());
11677 setup_execution_schema(&admin_pool, &schema).await;
11678 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
11679 let options = options.options([("search_path", schema.clone())]);
11680 let database = connect_test_database(options.clone()).await.unwrap();
11681 database
11682 .ensure_execution_transaction_schema()
11683 .await
11684 .unwrap();
11685 let intent = reserve_test_wrap_intent(&database).await;
11686 database
11687 .assign_execution_intent_nonce(intent.id, 7)
11688 .await
11689 .unwrap();
11690 let transaction = build_eip1559_transaction(
11691 42161,
11692 7,
11693 78_000,
11694 130_000_000,
11695 10_000_000,
11696 WETH_ADDRESS,
11697 U256::from(1_u64),
11698 Bytes::from(hex::decode("d0e30db0").unwrap()),
11699 );
11700 let (transaction_hash, raw_transaction) = sign_eip1559_transaction(
11701 transaction,
11702 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
11703 )
11704 .await
11705 .unwrap();
11706 database
11707 .add_execution_transaction_hash(
11708 intent.id,
11709 42161,
11710 &transaction_hash.to_string(),
11711 &raw_transaction,
11712 )
11713 .await
11714 .unwrap();
11715 let keys = payload_test_keys([0x41; 32], vec![], "restore-a");
11716
11717 database
11718 .ensure_execution_payload_storage(&keys)
11719 .await
11720 .unwrap();
11721 let operation: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11722 "SELECT operation FROM {schema}.execution_payload_state"
11723 )))
11724 .fetch_one(&admin_pool)
11725 .await
11726 .unwrap();
11727 assert_eq!(operation, "ready");
11728 drop(database);
11729
11730 let restarted = connect_test_database(options).await.unwrap();
11731 restarted
11732 .ensure_execution_payload_storage(&keys)
11733 .await
11734 .unwrap();
11735 let row = restarted
11736 .get_execution_transaction_hashes(intent.id)
11737 .await
11738 .unwrap()
11739 .pop()
11740 .unwrap();
11741 assert!(row.raw_transaction.is_none());
11742 let sealed_transaction = row.sealed_transaction.clone().unwrap();
11743 let stored_intent = restarted.get_execution_intent(intent.id).await.unwrap();
11744 let context = payload_context(&stored_intent, &row, keys.deployment_id()).unwrap();
11745 let alternate_envelope = keys.seal(&raw_transaction, &context).unwrap();
11746 assert_ne!(alternate_envelope, sealed_transaction);
11747 let repeated = restarted
11748 .add_execution_transaction_envelope(
11749 intent.id,
11750 42161,
11751 &transaction_hash.to_string(),
11752 &alternate_envelope,
11753 )
11754 .await
11755 .unwrap();
11756 let check = restarted
11757 .check_execution_payload_storage(Some(&keys), None, 1)
11758 .await
11759 .unwrap();
11760 assert_eq!(
11761 repeated.sealed_transaction,
11762 Some(sealed_transaction.clone())
11763 );
11764 assert_eq!(check.plaintext_rows, 0);
11765 assert_eq!(check.original_rows, 1);
11766 assert_eq!(check.replacement_rows, 0);
11767 assert_eq!(check.authenticated_rows, 1);
11768 assert!(!check.read_roles.is_empty());
11769
11770 sqlx::query(sqlx::AssertSqlSafe(format!(
11771 "UPDATE {schema}.execution_payload_key_state SET seals = 4294967295"
11772 )))
11773 .execute(&admin_pool)
11774 .await
11775 .unwrap();
11776 let exhausted = restarted
11777 .reserve_execution_payload_seal(&keys)
11778 .await
11779 .unwrap_err();
11780 assert!(exhausted.to_string().contains("seal limit"));
11781 let seals: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11782 "SELECT seals FROM {schema}.execution_payload_key_state"
11783 )))
11784 .fetch_one(&admin_pool)
11785 .await
11786 .unwrap();
11787 assert_eq!(seals, 4_294_967_295);
11788 sqlx::query(sqlx::AssertSqlSafe(format!(
11789 "UPDATE {schema}.execution_payload_key_state SET seals = 1"
11790 )))
11791 .execute(&admin_pool)
11792 .await
11793 .unwrap();
11794
11795 let missing_key = restarted
11796 .check_execution_payload_storage(None, None, 1)
11797 .await
11798 .unwrap_err();
11799 assert!(
11800 missing_key
11801 .to_string()
11802 .contains("no payload key is configured")
11803 );
11804 let restored_elsewhere = payload_test_keys([0x41; 32], vec![], "restore-b");
11805 let wrong_context = restarted
11806 .ensure_execution_payload_storage(&restored_elsewhere)
11807 .await
11808 .unwrap_err();
11809 assert!(wrong_context.to_string().contains("deployment ID"));
11810
11811 sqlx::query(sqlx::AssertSqlSafe(format!(
11812 "UPDATE {schema}.execution_transaction_hash \
11813 SET sealed_transaction = set_byte(sealed_transaction, octet_length(sealed_transaction) - 1, \
11814 get_byte(sealed_transaction, octet_length(sealed_transaction) - 1) # 1)"
11815 )))
11816 .execute(&admin_pool)
11817 .await
11818 .unwrap();
11819 assert!(
11820 restarted
11821 .check_execution_payload_storage(Some(&keys), None, 1)
11822 .await
11823 .is_err()
11824 );
11825 sqlx::query(sqlx::AssertSqlSafe(format!(
11826 "UPDATE {schema}.execution_transaction_hash SET sealed_transaction = $1"
11827 )))
11828 .bind(&sealed_transaction)
11829 .execute(&admin_pool)
11830 .await
11831 .unwrap();
11832
11833 for statement in [
11834 format!(
11835 "ALTER TABLE {schema}.execution_transaction_hash \
11836 DROP CONSTRAINT execution_transaction_payload_protected_check"
11837 ),
11838 format!("UPDATE {schema}.execution_transaction_hash SET sealed_transaction = NULL"),
11839 ] {
11840 sqlx::query(sqlx::AssertSqlSafe(statement))
11841 .execute(&admin_pool)
11842 .await
11843 .unwrap();
11844 }
11845 assert!(
11846 restarted
11847 .check_execution_payload_storage(Some(&keys), None, 1)
11848 .await
11849 .is_err()
11850 );
11851 sqlx::query(sqlx::AssertSqlSafe(format!(
11852 "UPDATE {schema}.execution_transaction_hash SET sealed_transaction = $1"
11853 )))
11854 .bind(&sealed_transaction)
11855 .execute(&admin_pool)
11856 .await
11857 .unwrap();
11858 sqlx::query(sqlx::AssertSqlSafe(format!(
11859 "ALTER TABLE {schema}.execution_transaction_hash \
11860 ADD CONSTRAINT execution_transaction_payload_protected_check CHECK ( \
11861 (payload_expected AND raw_transaction IS NULL AND sealed_transaction IS NOT NULL) \
11862 OR (NOT payload_expected AND raw_transaction IS NULL AND sealed_transaction IS NULL) \
11863 )"
11864 )))
11865 .execute(&admin_pool)
11866 .await
11867 .unwrap();
11868
11869 let rotated = payload_test_keys([0x52; 32], vec![[0x41; 32]], "restore-a");
11870 restarted
11871 .rewrap_execution_payload_storage(&rotated, 1)
11872 .await
11873 .unwrap();
11874 let rotated_check = restarted
11875 .check_execution_payload_storage(Some(&rotated), None, 1)
11876 .await
11877 .unwrap();
11878 assert_eq!(rotated_check.authenticated_rows, 1);
11879 assert_eq!(rotated_check.key_ids.len(), 1);
11880
11881 restarted
11882 .rollback_execution_payload_storage(&rotated, 1)
11883 .await
11884 .unwrap();
11885 let rolled_back = restarted
11886 .get_execution_transaction_hashes(intent.id)
11887 .await
11888 .unwrap()
11889 .pop()
11890 .unwrap();
11891 assert_eq!(
11892 rolled_back.raw_transaction.as_deref(),
11893 Some(raw_transaction.as_slice())
11894 );
11895 assert!(rolled_back.sealed_transaction.is_none());
11896 let legacy_check = restarted
11897 .check_execution_payload_storage(None, None, 1)
11898 .await
11899 .unwrap();
11900 assert!(!legacy_check.protected);
11901 assert_eq!(legacy_check.plaintext_rows, 1);
11902 assert_eq!(legacy_check.authenticated_rows, 1);
11903
11904 drop_execution_schema(&admin_pool, &schema).await;
11905 }
11906
11907 #[tokio::test]
11908 async fn protected_payload_storage_authenticates_multiple_execution_identities() {
11909 let Some((admin_pool, pg_config)) =
11910 connect_test_postgres("protected payload multiple identities").await
11911 else {
11912 return;
11913 };
11914 let schema = format!("protected_payload_identities_{}", std::process::id());
11915 setup_execution_schema(&admin_pool, &schema).await;
11916 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
11917 let database = connect_test_database(options.options([("search_path", schema.clone())]))
11918 .await
11919 .unwrap();
11920 database
11921 .ensure_execution_transaction_schema()
11922 .await
11923 .unwrap();
11924
11925 let private_keys = [
11926 TEST_PRIVATE_KEY,
11927 "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
11928 ];
11929
11930 for (index, private_key) in private_keys.into_iter().enumerate() {
11931 let signer = PrivateKeySigner::from_str(private_key).unwrap();
11932 let nonce = 7 + u64::try_from(index).unwrap();
11933 let intent =
11934 reserve_test_wrap_intent_for_wallet(&database, &signer.address().to_string()).await;
11935 database
11936 .assign_execution_intent_nonce(intent.id, nonce)
11937 .await
11938 .unwrap();
11939 let transaction = build_eip1559_transaction(
11940 42161,
11941 nonce,
11942 78_000,
11943 130_000_000,
11944 10_000_000,
11945 WETH_ADDRESS,
11946 U256::from(1_u64),
11947 Bytes::from(hex::decode("d0e30db0").unwrap()),
11948 );
11949 let (transaction_hash, raw_transaction) =
11950 sign_eip1559_transaction(transaction, &signer)
11951 .await
11952 .unwrap();
11953 database
11954 .add_execution_transaction_hash(
11955 intent.id,
11956 42161,
11957 &transaction_hash.to_string(),
11958 &raw_transaction,
11959 )
11960 .await
11961 .unwrap();
11962 }
11963
11964 let keys = payload_test_keys([0x61; 32], vec![], "multiple-identities");
11965 database
11966 .ensure_execution_payload_storage(&keys)
11967 .await
11968 .unwrap();
11969 let lease = database
11970 .require_execution_payload_storage(
11971 &keys,
11972 PayloadPolicy {
11973 chain_id: 42161,
11974 signer: Address::from_str(WALLET).unwrap(),
11975 gas_limit: 1_000_000,
11976 max_fee_per_gas: 1_000_000_000,
11977 },
11978 1,
11979 )
11980 .await
11981 .unwrap();
11982 drop(lease);
11983 let check = database
11984 .check_execution_payload_storage(Some(&keys), None, 1)
11985 .await
11986 .unwrap();
11987
11988 assert!(check.protected);
11989 assert_eq!(check.plaintext_rows, 0);
11990 assert_eq!(check.original_rows, 2);
11991 assert_eq!(check.replacement_rows, 0);
11992 assert_eq!(check.authenticated_rows, 2);
11993 assert_eq!(check.key_ids.len(), 1);
11994
11995 drop_execution_schema(&admin_pool, &schema).await;
11996 }
11997
11998 #[rstest]
11999 #[case::finalized(TransactionStatus::Finalized)]
12000 #[case::reverted(TransactionStatus::Reverted)]
12001 #[tokio::test]
12002 async fn protected_payload_storage_ignores_current_policy_for_released_terminal_history(
12003 #[case] status: TransactionStatus,
12004 ) {
12005 let Some((admin_pool, pg_config)) =
12006 connect_test_postgres("protected payload terminal history").await
12007 else {
12008 return;
12009 };
12010 let schema = format!(
12011 "protected_payload_terminal_{}_{}",
12012 status.as_str(),
12013 std::process::id()
12014 );
12015 setup_execution_schema(&admin_pool, &schema).await;
12016 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
12017 let database = connect_test_database(options.options([("search_path", schema.clone())]))
12018 .await
12019 .unwrap();
12020 database
12021 .ensure_execution_transaction_schema()
12022 .await
12023 .unwrap();
12024 let (intent, transaction_hash, _) = persist_test_wrap_broadcast(&database, None).await;
12025 database
12026 .record_execution_status(
12027 intent.id,
12028 &transaction_hash.to_string(),
12029 status,
12030 None,
12031 None,
12032 None,
12033 None,
12034 None,
12035 )
12036 .await
12037 .unwrap();
12038 database
12039 .mark_execution_event_emitted(intent.id, "terminal")
12040 .await
12041 .unwrap();
12042 let keys = payload_test_keys([0x62; 32], vec![], "terminal-history");
12043 database
12044 .ensure_execution_payload_storage(&keys)
12045 .await
12046 .unwrap();
12047
12048 let lease = database
12049 .require_execution_payload_storage(
12050 &keys,
12051 PayloadPolicy {
12052 chain_id: 42161,
12053 signer: Address::from_str(WALLET).unwrap(),
12054 gas_limit: 1,
12055 max_fee_per_gas: 1,
12056 },
12057 1,
12058 )
12059 .await
12060 .unwrap();
12061 drop(lease);
12062 let check = database
12063 .check_execution_payload_storage(Some(&keys), None, 1)
12064 .await
12065 .unwrap();
12066 let active_intent = reserve_test_wrap_intent(&database).await;
12067 database
12068 .assign_execution_intent_nonce(active_intent.id, 8)
12069 .await
12070 .unwrap();
12071 let active_intent = database
12072 .get_execution_intent(active_intent.id)
12073 .await
12074 .unwrap();
12075 let active_transaction = build_eip1559_transaction(
12076 42161,
12077 8,
12078 78_000,
12079 130_000_000,
12080 10_000_000,
12081 WETH_ADDRESS,
12082 U256::from(1_u64),
12083 Bytes::from(hex::decode("d0e30db0").unwrap()),
12084 );
12085 let (active_hash, active_raw_transaction) = sign_eip1559_transaction(
12086 active_transaction,
12087 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
12088 )
12089 .await
12090 .unwrap();
12091 persist_test_payload(
12092 &database,
12093 Some(&keys),
12094 &active_intent,
12095 active_hash,
12096 &active_raw_transaction,
12097 )
12098 .await;
12099 let active_error = match database
12100 .require_execution_payload_storage(
12101 &keys,
12102 PayloadPolicy {
12103 chain_id: 42161,
12104 signer: Address::from_str(WALLET).unwrap(),
12105 gas_limit: 1,
12106 max_fee_per_gas: 1,
12107 },
12108 1,
12109 )
12110 .await
12111 {
12112 Ok(_) => panic!("active execution payload unexpectedly passed current policy"),
12113 Err(e) => e,
12114 };
12115 let active_error = format!("{active_error:#}");
12116
12117 assert!(check.protected);
12118 assert_eq!(check.authenticated_rows, 1);
12119 assert!(
12120 active_error.contains(&format!(
12121 "execution intent {} transaction {active_hash} violates current execution policy",
12122 active_intent.id
12123 )),
12124 "was: {active_error}"
12125 );
12126 assert!(
12127 active_error.contains("gas limit 78000 exceeds configured ceiling 1"),
12128 "was: {active_error}"
12129 );
12130
12131 drop_execution_schema(&admin_pool, &schema).await;
12132 }
12133
12134 #[allow(unsafe_code)] #[tokio::test]
12136 async fn rollback_blocks_execution_until_protection_and_full_check_succeed() {
12137 let state = ready_rpc_state();
12138 let Some((admin_pool, schema, mut client, state)) =
12139 execution_client_with_database("payload_rollback_reactivation", state).await
12140 else {
12141 return;
12142 };
12143 unsafe { std::env::set_var("BLOCKCHAIN_TEST_PRIVATE_KEY", TEST_PRIVATE_KEY) };
12145 client.disconnect().await.unwrap();
12146 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
12147 replace_exec_event_sender(sender);
12148 client.start().unwrap();
12149
12150 client.rollback_payload_storage(1).await.unwrap();
12151 let unprotected = client.check_payload_storage(1).await.unwrap();
12152 let error = client.connect().await.unwrap_err();
12153
12154 assert!(!unprotected.protected);
12155 assert_eq!(unprotected.plaintext_rows, 0);
12156 assert_eq!(unprotected.authenticated_rows, 0);
12157 assert!(
12158 error
12159 .to_string()
12160 .contains("Postgres execution requires protected payload storage"),
12161 "was: {error}"
12162 );
12163 assert!(client.signer.is_none());
12164 assert!(state.recorded_requests().is_empty());
12165
12166 client.protect_payload_storage().await.unwrap();
12167 let protected = client.check_payload_storage(1).await.unwrap();
12168 assert!(protected.protected);
12169 assert_eq!(protected.plaintext_rows, 0);
12170 assert_eq!(protected.authenticated_rows, 0);
12171
12172 client.connect().await.unwrap();
12173
12174 assert!(client.is_connected());
12175 assert!(client.transaction_executor().is_ok());
12176 assert!(!state.recorded_requests().is_empty());
12177
12178 drop(client);
12179 drop_execution_schema(&admin_pool, &schema).await;
12180 }
12181
12182 #[rstest]
12183 #[case::active_key(true)]
12184 #[case::deployment_identity(false)]
12185 #[tokio::test]
12186 async fn postgres_connect_rejects_missing_payload_identity_before_rpc(
12187 #[case] remove_active_key: bool,
12188 ) {
12189 let test_name = if remove_active_key {
12190 "payload_connect_missing_active_key"
12191 } else {
12192 "payload_connect_missing_deployment"
12193 };
12194 let Some((admin_pool, schema, mut client, state)) =
12195 execution_client_with_database(test_name, ready_rpc_state()).await
12196 else {
12197 return;
12198 };
12199 client.disconnect().await.unwrap();
12200 client.payload_keys = None;
12201 if remove_active_key {
12202 client.config.payload_key_env = None;
12203 client.config.payload_deployment_id = None;
12204 } else {
12205 client.config.payload_deployment_id = None;
12206 }
12207
12208 let error = client.connect().await.unwrap_err();
12209
12210 assert!(
12211 error.to_string().contains(if remove_active_key {
12212 "Postgres execution requires an active payload key and deployment identity"
12213 } else {
12214 "Payload deployment ID is required"
12215 }),
12216 "was: {error}"
12217 );
12218 assert!(client.signer.is_none());
12219 assert!(client.payload_keys.is_none());
12220 assert!(state.recorded_requests().is_empty());
12221
12222 drop(client);
12223 drop_execution_schema(&admin_pool, &schema).await;
12224 }
12225
12226 #[tokio::test]
12227 async fn postgres_connect_rejects_missing_envelope_key_before_rpc() {
12228 let Some((admin_pool, schema, mut client, state)) = execution_client_with_database(
12229 "payload_connect_missing_envelope_key",
12230 ready_rpc_state(),
12231 )
12232 .await
12233 else {
12234 return;
12235 };
12236 let database = client.cache.database.as_ref().unwrap().clone();
12237 persist_test_wrap_broadcast(&database, client.payload_keys.as_deref()).await;
12238 client.disconnect().await.unwrap();
12239 client.payload_keys = None;
12240 let keys = set_test_payload_key(&mut client, [0xb6; 32], &schema);
12241 sqlx::query(sqlx::AssertSqlSafe(format!(
12242 "UPDATE {schema}.execution_payload_state SET active_key_id = $1 \
12243 WHERE component = 'signed_transactions'"
12244 )))
12245 .bind(keys.active_key_id().as_slice())
12246 .execute(&admin_pool)
12247 .await
12248 .unwrap();
12249
12250 let error = client.connect().await.unwrap_err();
12251
12252 assert!(
12253 error
12254 .to_string()
12255 .contains("Stored execution payload requires unavailable key"),
12256 "was: {error}"
12257 );
12258 assert!(client.signer.is_none());
12259 assert!(client.payload_keys.is_none());
12260 assert!(state.recorded_requests().is_empty());
12261
12262 drop(client);
12263 drop_execution_schema(&admin_pool, &schema).await;
12264 }
12265
12266 #[tokio::test]
12267 async fn payload_action_lease_blocks_rewrap_transition_until_release() {
12268 let Some((admin_pool, schema, client, _)) = execution_client_with_unprotected_database(
12269 "payload_action_lease",
12270 execution_rpc_state(),
12271 )
12272 .await
12273 else {
12274 return;
12275 };
12276 let database = client.cache.database.as_ref().unwrap();
12277 let keys = payload_test_keys([0x71; 32], vec![], "action-lease");
12278 database
12279 .ensure_execution_payload_storage(&keys)
12280 .await
12281 .unwrap();
12282 let lease = database
12283 .acquire_execution_payload_lease(&keys)
12284 .await
12285 .unwrap();
12286 let rotated = payload_test_keys([0x72; 32], vec![[0x71; 32]], "action-lease");
12287 let mut operation = Box::pin(database.rewrap_execution_payload_storage(&rotated, 1));
12288
12289 assert!(
12290 tokio::time::timeout(Duration::from_millis(50), &mut operation)
12291 .await
12292 .is_err()
12293 );
12294 let state: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
12295 "SELECT operation FROM {schema}.execution_payload_state"
12296 )))
12297 .fetch_one(&admin_pool)
12298 .await
12299 .unwrap();
12300 assert_eq!(state, "ready");
12301
12302 drop(lease);
12303 tokio::time::timeout(Duration::from_secs(2), operation)
12304 .await
12305 .unwrap()
12306 .unwrap();
12307 let state: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
12308 "SELECT operation FROM {schema}.execution_payload_state"
12309 )))
12310 .fetch_one(&admin_pool)
12311 .await
12312 .unwrap();
12313 assert_eq!(state, "ready");
12314
12315 drop_execution_schema(&admin_pool, &schema).await;
12316 }
12317
12318 #[tokio::test]
12319 async fn payload_infrastructure_checks_are_scoped_to_the_execution_schema() {
12320 let Some((admin_pool, pg_config)) = connect_test_postgres("payload schema isolation").await
12321 else {
12322 return;
12323 };
12324 let schema_a = format!("payload_schema_a_{}", std::process::id());
12325 let schema_b = format!("payload_schema_b_{}", std::process::id());
12326 setup_execution_schema(&admin_pool, &schema_a).await;
12327 setup_execution_schema(&admin_pool, &schema_b).await;
12328 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
12329 let database_a =
12330 connect_test_database(options.clone().options([("search_path", schema_a.clone())]))
12331 .await
12332 .unwrap();
12333 let database_b =
12334 connect_test_database(options.options([("search_path", schema_b.clone())]))
12335 .await
12336 .unwrap();
12337 database_a
12338 .ensure_execution_transaction_schema()
12339 .await
12340 .unwrap();
12341 database_b
12342 .ensure_execution_transaction_schema()
12343 .await
12344 .unwrap();
12345 let keys_a = payload_test_keys([0x91; 32], vec![], "schema-a");
12346 let keys_b = payload_test_keys([0x92; 32], vec![], "schema-b");
12347
12348 database_a
12349 .ensure_execution_payload_storage(&keys_a)
12350 .await
12351 .unwrap();
12352 database_b
12353 .ensure_execution_payload_storage(&keys_b)
12354 .await
12355 .unwrap();
12356
12357 for statement in [
12358 format!(
12359 "DROP TRIGGER execution_transaction_payload_fence ON \
12360 {schema_a}.execution_transaction_hash"
12361 ),
12362 format!(
12363 "ALTER TABLE {schema_a}.execution_transaction_hash \
12364 DROP CONSTRAINT execution_transaction_payload_protected_check"
12365 ),
12366 ] {
12367 sqlx::query(sqlx::AssertSqlSafe(statement))
12368 .execute(&admin_pool)
12369 .await
12370 .unwrap();
12371 }
12372
12373 let error = database_a
12374 .ensure_execution_payload_storage(&keys_a)
12375 .await
12376 .unwrap_err();
12377
12378 assert!(
12379 error
12380 .to_string()
12381 .contains("without its write fence or constraint"),
12382 "was: {error}"
12383 );
12384
12385 drop_execution_schema(&admin_pool, &schema_a).await;
12386 drop_execution_schema(&admin_pool, &schema_b).await;
12387 }
12388
12389 #[tokio::test]
12390 async fn execution_fresh_schema_preserves_nullable_wallet_address() {
12391 let Some((admin_pool, _)) = connect_test_postgres("fresh execution schema").await else {
12392 return;
12393 };
12394 let schema = format!("execution_fresh_test_{}", std::process::id());
12395 let mut transaction = admin_pool.begin().await.unwrap();
12396 sqlx::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {schema}")))
12397 .execute(&mut *transaction)
12398 .await
12399 .unwrap();
12400 sqlx::query(sqlx::AssertSqlSafe(format!(
12401 "SET LOCAL search_path TO {schema}"
12402 )))
12403 .execute(&mut *transaction)
12404 .await
12405 .unwrap();
12406 sqlx::query("CREATE TABLE chain (chain_id INTEGER PRIMARY KEY)")
12407 .execute(&mut *transaction)
12408 .await
12409 .unwrap();
12410 sqlx::query("INSERT INTO chain (chain_id) VALUES (42161)")
12411 .execute(&mut *transaction)
12412 .await
12413 .unwrap();
12414 sqlx::query(sqlx::AssertSqlSafe(execution_transaction_create_sql()))
12415 .execute(&mut *transaction)
12416 .await
12417 .unwrap();
12418 sqlx::query(
12419 "INSERT INTO execution_transaction \
12420 (chain_id, nonce, transaction_hash, purpose, status) \
12421 VALUES (42161, 7, '0xfresh-legacy', 'wrap', 'rejected')",
12422 )
12423 .execute(&mut *transaction)
12424 .await
12425 .unwrap();
12426
12427 let is_nullable: String = sqlx::query_scalar(
12428 "SELECT is_nullable FROM information_schema.columns \
12429 WHERE table_schema = $1 AND table_name = 'execution_transaction' \
12430 AND column_name = 'wallet_address'",
12431 )
12432 .bind(&schema)
12433 .fetch_one(&mut *transaction)
12434 .await
12435 .unwrap();
12436 let wallet_address: Option<String> = sqlx::query_scalar(
12437 "SELECT wallet_address FROM execution_transaction \
12438 WHERE transaction_hash = '0xfresh-legacy'",
12439 )
12440 .fetch_one(&mut *transaction)
12441 .await
12442 .unwrap();
12443
12444 assert_eq!(is_nullable, "YES");
12445 assert_eq!(wallet_address, None);
12446 transaction.rollback().await.unwrap();
12447 }
12448
12449 #[tokio::test]
12450 async fn execution_schema_migration_preserves_existing_rows() {
12451 let Some((admin_pool, pg_config)) =
12452 connect_test_postgres("execution schema migration").await
12453 else {
12454 return;
12455 };
12456 let schema = format!("execution_migration_test_{}", std::process::id());
12457 setup_execution_schema(&admin_pool, &schema).await;
12458 sqlx::query(sqlx::AssertSqlSafe(format!(
12459 "INSERT INTO {schema}.execution_transaction \
12460 (chain_id, nonce, transaction_hash, purpose, status) \
12461 VALUES (42161, 7, '0xlegacy', 'wrap', 'rejected')"
12462 )))
12463 .execute(&admin_pool)
12464 .await
12465 .unwrap();
12466
12467 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
12468 let db_options = db_options.options([("search_path", schema.clone())]);
12469 let database = connect_test_database(db_options).await.unwrap();
12470 database
12471 .ensure_execution_transaction_schema()
12472 .await
12473 .unwrap();
12474 let legacy = database
12475 .get_execution_transaction(42161, "0xlegacy")
12476 .await
12477 .unwrap()
12478 .unwrap();
12479 assert_eq!(legacy.purpose, "wrap");
12480 assert_eq!(legacy.status, "rejected");
12481 assert_eq!(legacy.client_order_id, None);
12482 assert_eq!(legacy.wallet_address, None);
12483 let is_nullable: String = sqlx::query_scalar(
12484 "SELECT is_nullable FROM information_schema.columns \
12485 WHERE table_schema = $1 AND table_name = 'execution_transaction' \
12486 AND column_name = 'wallet_address'",
12487 )
12488 .bind(&schema)
12489 .fetch_one(&admin_pool)
12490 .await
12491 .unwrap();
12492 let fence_error = database
12493 .add_execution_transaction(
12494 42161,
12495 WALLET,
12496 8,
12497 "0xswap",
12498 "swap",
12499 "pending",
12500 Some("O-SWAP-001"),
12501 )
12502 .await
12503 .err()
12504 .unwrap();
12505 assert!(
12506 fence_error
12507 .to_string()
12508 .contains("Legacy execution writer refused"),
12509 "was: {fence_error}"
12510 );
12511 assert_eq!(is_nullable, "YES");
12512
12513 drop_execution_schema(&admin_pool, &schema).await;
12514 }
12515
12516 #[tokio::test]
12517 async fn execution_schema_migration_drops_legacy_wallet_not_null() {
12518 let Some((admin_pool, pg_config)) =
12519 connect_test_postgres("legacy wallet address schema migration").await
12520 else {
12521 return;
12522 };
12523 let schema = format!("execution_wallet_migration_test_{}", std::process::id());
12524 setup_execution_schema(&admin_pool, &schema).await;
12525 sqlx::query(sqlx::AssertSqlSafe(format!(
12526 "ALTER TABLE {schema}.execution_transaction \
12527 ADD COLUMN wallet_address TEXT NOT NULL"
12528 )))
12529 .execute(&admin_pool)
12530 .await
12531 .unwrap();
12532 sqlx::query(sqlx::AssertSqlSafe(format!(
12533 "INSERT INTO {schema}.execution_transaction \
12534 (chain_id, wallet_address, nonce, transaction_hash, purpose, status) \
12535 VALUES (42161, '{WALLET}', 7, '0xlegacy-wallet', 'wrap', 'rejected')"
12536 )))
12537 .execute(&admin_pool)
12538 .await
12539 .unwrap();
12540
12541 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
12542 let db_options = db_options.options([("search_path", schema.clone())]);
12543 let database = connect_test_database(db_options).await.unwrap();
12544 database
12545 .ensure_execution_transaction_schema()
12546 .await
12547 .unwrap();
12548 let legacy = database
12549 .get_execution_transaction(42161, "0xlegacy-wallet")
12550 .await
12551 .unwrap()
12552 .unwrap();
12553 let is_nullable: String = sqlx::query_scalar(
12554 "SELECT is_nullable FROM information_schema.columns \
12555 WHERE table_schema = $1 AND table_name = 'execution_transaction' \
12556 AND column_name = 'wallet_address'",
12557 )
12558 .bind(&schema)
12559 .fetch_one(&admin_pool)
12560 .await
12561 .unwrap();
12562
12563 assert_eq!(legacy.wallet_address.as_deref(), Some(WALLET));
12564 assert_eq!(is_nullable, "YES");
12565 drop_execution_schema(&admin_pool, &schema).await;
12566 }
12567
12568 #[tokio::test]
12569 async fn execution_schema_migration_refuses_unresolved_legacy_rows() {
12570 let Some((admin_pool, pg_config)) =
12571 connect_test_postgres("unsafe execution schema migration").await
12572 else {
12573 return;
12574 };
12575 let schema = format!("execution_unsafe_migration_test_{}", std::process::id());
12576 setup_execution_schema(&admin_pool, &schema).await;
12577 sqlx::query(sqlx::AssertSqlSafe(format!(
12578 "INSERT INTO {schema}.execution_transaction \
12579 (chain_id, nonce, transaction_hash, purpose, status) \
12580 VALUES (42161, 7, '0xunresolved', 'wrap', 'pending')"
12581 )))
12582 .execute(&admin_pool)
12583 .await
12584 .unwrap();
12585 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
12586 let db_options = db_options.options([("search_path", schema.clone())]);
12587 let database = connect_test_database(db_options).await.unwrap();
12588
12589 let error = database
12590 .ensure_execution_transaction_schema()
12591 .await
12592 .unwrap_err();
12593 let legacy_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
12594 "SELECT COUNT(*) FROM {schema}.execution_transaction \
12595 WHERE transaction_hash = '0xunresolved' AND status = 'pending'"
12596 )))
12597 .fetch_one(&admin_pool)
12598 .await
12599 .unwrap();
12600 let v2_table: Option<String> = sqlx::query_scalar("SELECT to_regclass($1)::TEXT")
12601 .bind(format!("{schema}.execution_intent"))
12602 .fetch_one(&admin_pool)
12603 .await
12604 .unwrap();
12605
12606 assert!(
12607 error
12608 .to_string()
12609 .contains("Cannot safely migrate 1 unresolved execution schema version 1"),
12610 "was: {error}"
12611 );
12612 assert_eq!(legacy_count, 1);
12613 assert_eq!(v2_table, None);
12614
12615 drop_execution_schema(&admin_pool, &schema).await;
12616 }
12617
12618 #[rstest]
12619 fn receipt_max_polls_derives_from_timeout() {
12620 assert_eq!(receipt_max_polls(0), 1);
12621 assert_eq!(receipt_max_polls(1), 1);
12622 assert_eq!(receipt_max_polls(60), 60);
12623 assert_eq!(receipt_max_polls(u64::MAX), u32::MAX);
12624 assert_eq!(receipt_timeout(0), Duration::from_secs(1));
12625 assert_eq!(receipt_timeout(60), Duration::from_secs(60));
12626 assert_eq!(
12627 receipt_timeout(u64::MAX),
12628 Duration::from_secs(u64::from(u32::MAX))
12629 );
12630 }
12631
12632 #[rstest]
12633 fn submit_order_errors_when_order_not_cached() {
12634 let client = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string())).0;
12635 let mut cmd = submit_order_cmd(&test_market_sell_order(test_pool().instrument_id));
12636 cmd.client_order_id = ClientOrderId::from("O-UNKNOWN");
12637
12638 let error = client.submit_order(cmd).unwrap_err();
12639
12640 assert!(!error.to_string().is_empty());
12641 }
12642
12643 #[tokio::test]
12644 async fn submit_order_denies_pool_fee_above_uint24() {
12645 let cache = Rc::new(RefCell::new(Cache::default()));
12646 let mut pool = test_pool();
12647 pool.fee = Some(16_777_216); cache.borrow_mut().add_pool(pool.clone()).unwrap();
12649 let order = test_market_sell_order(pool.instrument_id);
12650 cache
12651 .borrow_mut()
12652 .add_order(order.clone(), None, None, false)
12653 .unwrap();
12654 let core = ExecutionClientCore::new(
12655 TraderId::from("TRADER-001"),
12656 ClientId::from("BLOCKCHAIN-001"),
12657 *BLOCKCHAIN_VENUE,
12658 OmsType::Netting,
12659 AccountId::from("BLOCKCHAIN-001"),
12660 AccountType::Wallet,
12661 None,
12662 cache,
12663 );
12664 let mut client =
12665 BlockchainExecutionClient::new(core, test_config("http://127.0.0.1:1".to_string()))
12666 .unwrap();
12667 let mut receiver = start_with_events(&mut client);
12668
12669 client.submit_order(submit_order_cmd(&order)).unwrap();
12670
12671 let events = collect_order_events(&mut receiver);
12672 assert_eq!(events.len(), 1);
12673 let OrderEventAny::Denied(denied) = &events[0] else {
12674 panic!("expected OrderDenied, was {:?}", events[0]);
12675 };
12676 assert!(
12677 denied.reason.as_str().contains("exceeds uint24"),
12678 "was: {}",
12679 denied.reason
12680 );
12681 }
12682
12683 #[tokio::test]
12684 async fn submit_order_denies_uninitialized_profiler() {
12685 let cache = Rc::new(RefCell::new(Cache::default()));
12686 let pool = test_pool();
12687 cache.borrow_mut().add_pool(pool.clone()).unwrap();
12688 let order = test_market_sell_order(pool.instrument_id);
12689 cache
12690 .borrow_mut()
12691 .add_order(order.clone(), None, None, false)
12692 .unwrap();
12693 cache
12694 .borrow_mut()
12695 .add_pool_profiler(PoolProfiler::new(Arc::new(pool)))
12696 .unwrap();
12697 let core = ExecutionClientCore::new(
12698 TraderId::from("TRADER-001"),
12699 ClientId::from("BLOCKCHAIN-001"),
12700 *BLOCKCHAIN_VENUE,
12701 OmsType::Netting,
12702 AccountId::from("BLOCKCHAIN-001"),
12703 AccountType::Wallet,
12704 None,
12705 cache,
12706 );
12707 let mut client =
12708 BlockchainExecutionClient::new(core, test_config("http://127.0.0.1:1".to_string()))
12709 .unwrap();
12710 let mut receiver = start_with_events(&mut client);
12711
12712 client.submit_order(submit_order_cmd(&order)).unwrap();
12713
12714 let events = collect_order_events(&mut receiver);
12715 assert_eq!(events.len(), 1);
12716 let OrderEventAny::Denied(denied) = &events[0] else {
12717 panic!("expected OrderDenied, was {:?}", events[0]);
12718 };
12719 assert!(
12720 denied.reason.as_str().contains("is not initialized"),
12721 "was: {}",
12722 denied.reason
12723 );
12724 }
12725
12726 #[tokio::test]
12727 async fn submit_order_denies_when_quote_cannot_fill_order() {
12728 let cache = Rc::new(RefCell::new(Cache::default()));
12729 let pool = test_pool();
12730 cache.borrow_mut().add_pool(pool.clone()).unwrap();
12731 let order = test_market_sell_order(pool.instrument_id);
12732 cache
12733 .borrow_mut()
12734 .add_order(order.clone(), None, None, false)
12735 .unwrap();
12736 cache
12738 .borrow_mut()
12739 .add_pool_profiler(test_profiler_with_range(
12740 &pool,
12741 FIXTURE_BLOCK,
12742 FIXTURE_BLOCK_HASH,
12743 U160::from(1u128 << 96),
12744 -10,
12745 10,
12746 1,
12747 ))
12748 .unwrap();
12749 let core = ExecutionClientCore::new(
12750 TraderId::from("TRADER-001"),
12751 ClientId::from("BLOCKCHAIN-001"),
12752 *BLOCKCHAIN_VENUE,
12753 OmsType::Netting,
12754 AccountId::from("BLOCKCHAIN-001"),
12755 AccountType::Wallet,
12756 None,
12757 cache,
12758 );
12759 let mut client =
12760 BlockchainExecutionClient::new(core, test_config("http://127.0.0.1:1".to_string()))
12761 .unwrap();
12762 let mut receiver = start_with_events(&mut client);
12763
12764 client.submit_order(submit_order_cmd(&order)).unwrap();
12765
12766 let events = collect_order_events(&mut receiver);
12767 assert_eq!(events.len(), 1);
12768 let OrderEventAny::Denied(denied) = &events[0] else {
12769 panic!("expected OrderDenied, was {:?}", events[0]);
12770 };
12771 assert!(
12772 denied.reason.as_str().contains("cannot fill the order"),
12773 "was: {}",
12774 denied.reason
12775 );
12776 }
12777
12778 #[tokio::test]
12779 async fn submit_order_applies_slippage_param_override_at_ceiling() {
12780 let Some((admin_pool, schema, mut client, state, _)) = swap_client_with_database(
12781 "execution_submit_slippage_override_test",
12782 swap_rpc_state_with_min_amount_out(expected_min_amount_out(200)).await,
12783 )
12784 .await
12785 else {
12786 return;
12787 };
12788 let order = test_market_sell_order(test_pool().instrument_id);
12789 let mut cmd = submit_order_cmd(&order);
12790 cmd.params = Some(serde_json::from_str(r#"{"slippage_bps": 200}"#).unwrap());
12792 let mut receiver = start_with_events(&mut client);
12793 let expected_min_out = expected_min_amount_out(200);
12794
12795 client.submit_order(cmd).unwrap();
12796 await_pending_tasks(&client).await;
12797
12798 let events = collect_order_events(&mut receiver);
12799 assert_swap_submitted_and_filled(&events);
12800
12801 let (_, expected_raw) = expected_swap_tx(expected_min_out).await;
12802 let broadcasts: Vec<_> = state
12803 .recorded_requests()
12804 .into_iter()
12805 .filter(|request| request["method"] == "eth_sendRawTransaction")
12806 .collect();
12807 assert_eq!(broadcasts.len(), 1);
12808 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
12809
12810 drop_execution_schema(&admin_pool, &schema).await;
12811 }
12812
12813 #[tokio::test]
12814 async fn submit_order_accepts_quote_fresh_at_max_age_boundary() {
12815 let min_amount_out = expected_min_amount_out(50);
12816 let (tx_hash, _) = expected_swap_tx(min_amount_out).await;
12817 let head = finalized_swap_block(tx_hash, min_amount_out);
12818 let state = swap_rpc_state()
12819 .await
12820 .with_response("eth_getBlockByNumber", &head)
12821 .with_parameter_response("eth_getBlockByNumber", FIXTURE_BLOCK_PARAM, BLOCK_BY_NUMBER);
12822 let Some((admin_pool, schema, mut client, state, cache)) =
12823 swap_client_with_database_config(
12824 "execution_submit_fresh_boundary_test",
12825 state,
12826 |http_rpc_url| {
12827 let mut config = test_config(http_rpc_url);
12828 config.max_quote_age_blocks = Some(1);
12829 config
12830 },
12831 )
12832 .await
12833 else {
12834 return;
12835 };
12836 let pool = test_pool();
12837 cache
12838 .borrow_mut()
12839 .add_pool_profiler(test_profiler_at_block(
12840 &pool,
12841 FIXTURE_BLOCK,
12842 FIXTURE_BLOCK_HASH,
12843 ))
12844 .unwrap();
12845 let order = test_market_sell_order(pool.instrument_id);
12846 let mut receiver = start_with_events(&mut client);
12847
12848 client.submit_order(submit_order_cmd(&order)).unwrap();
12849 await_pending_tasks(&client).await;
12850
12851 let events = collect_order_events(&mut receiver);
12852 assert_eq!(events.len(), 1, "was: {events:?}");
12853 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
12854 assert_eq!(
12855 state
12856 .recorded_requests()
12857 .iter()
12858 .filter(|request| request["method"] == "eth_sendRawTransaction")
12859 .count(),
12860 1
12861 );
12862
12863 drop_execution_schema(&admin_pool, &schema).await;
12864 }
12865
12866 #[tokio::test]
12867 async fn submit_order_sells_base_token_from_token1_position() {
12868 let chain = Arc::new(chains::ARBITRUM.clone());
12872 let dex = UNISWAP_V3.dex.clone();
12873 let usdc = Token::new(
12874 chain.clone(),
12875 address!("af88d065e77c8cC2239327C5EDb3A432268e5831"),
12876 "USD Coin".to_string(),
12877 "USDC".to_string(),
12878 6,
12879 );
12880 let weth = Token::new(
12881 chain.clone(),
12882 address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
12883 "Wrapped Ether".to_string(),
12884 "WETH".to_string(),
12885 18,
12886 );
12887 let pool = Pool::new(
12888 chain,
12889 dex,
12890 address!("C6962004f452bE9203591991D15f6b388e09E8D0"),
12891 PoolIdentifier::from_address(address!("C6962004f452bE9203591991D15f6b388e09E8D0")),
12892 55_000_000,
12893 usdc,
12894 weth,
12895 Some(500),
12896 Some(10),
12897 UnixNanos::default(),
12898 );
12899
12900 let cache = Rc::new(RefCell::new(Cache::default()));
12901 cache.borrow_mut().add_pool(pool.clone()).unwrap();
12902 let order = test_market_sell_order(pool.instrument_id);
12903 cache
12904 .borrow_mut()
12905 .add_order(order.clone(), None, None, false)
12906 .unwrap();
12907 cache
12909 .borrow_mut()
12910 .add_pool_profiler(test_profiler_with_state(
12911 &pool,
12912 FIXTURE_BLOCK,
12913 U160::from(2u128 << 96),
12914 TEST_LIQUIDITY,
12915 ))
12916 .unwrap();
12917
12918 let (admin_pool, pg_config) = match connect_test_postgres("orientation").await {
12919 Some(setup) => setup,
12920 None => return,
12921 };
12922 let schema = format!("execution_submit_orientation_test_{}", std::process::id());
12923 setup_execution_schema(&admin_pool, &schema).await;
12924 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
12925 let db_options = db_options.options([("search_path", schema.clone())]);
12926 let database = connect_test_database(db_options).await.unwrap();
12927
12928 let state = swap_rpc_state()
12929 .await
12930 .with_call_response(POOL_TOKEN0_SELECTOR, CALL_USDC)
12931 .with_call_response(POOL_TOKEN1_SELECTOR, CALL_WETH)
12932 .with_response("eth_getTransactionReceipt", RECEIPT_NULL);
12933 let addr = start_mock_rpc_server(state.clone()).await;
12934 let core = ExecutionClientCore::new(
12935 TraderId::from("TRADER-001"),
12936 ClientId::from("BLOCKCHAIN-001"),
12937 *BLOCKCHAIN_VENUE,
12938 OmsType::Netting,
12939 AccountId::from("BLOCKCHAIN-001"),
12940 AccountType::Wallet,
12941 None,
12942 cache,
12943 );
12944 let mut config = test_config(format!("http://{addr}"));
12945 let result = |response: &str| {
12946 serde_json::from_str::<serde_json::Value>(response).unwrap()["result"]
12947 .as_str()
12948 .unwrap()
12949 .to_string()
12950 };
12951 {
12952 let verification = config.verification.as_mut().unwrap();
12953 let pool_identity = &mut verification.deployment_manifest.pools[0];
12954 pool_identity.token0 = USDC.to_string();
12955 pool_identity.token1 = WETH.to_string();
12956 let pool_contract = verification
12957 .deployment_manifest
12958 .contracts
12959 .iter_mut()
12960 .find(|contract| contract.role == BlockchainContractRole::Pool)
12961 .unwrap();
12962
12963 for probe in &mut pool_contract.probes {
12964 if probe.call_data.starts_with(POOL_TOKEN0_SELECTOR) {
12965 probe.expected_output = result(CALL_USDC);
12966 } else if probe.call_data.starts_with(POOL_TOKEN1_SELECTOR) {
12967 probe.expected_output = result(CALL_WETH);
12968 }
12969 }
12970 }
12971 refresh_test_manifest_digest(&mut config);
12972 let mut client = BlockchainExecutionClient::new(core, config).unwrap();
12973 client.cache.database = Some(database);
12974 client
12975 .cache
12976 .ensure_execution_transaction_schema()
12977 .await
12978 .unwrap();
12979 protect_test_storage(&mut client, &schema).await;
12980 initialize_test_verification_ledger(&client).await;
12981 client.signer = Some(Arc::new(
12982 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
12983 ));
12984 client.core.set_connected();
12985 let mut receiver = start_with_events(&mut client);
12986
12987 let profiler = test_profiler_with_state(
12988 &pool,
12989 FIXTURE_BLOCK,
12990 U160::from(2u128 << 96),
12991 TEST_LIQUIDITY,
12992 );
12993 let quote = profiler
12994 .swap_exact_in(U256::from(1_000_000_000_000_000u64), false, None)
12995 .unwrap();
12996 let quoted = exact_output_amount("e, false).unwrap();
12997 let expected_min_out = derive_min_amount_out(quoted, 50).unwrap();
12998 let wrong_direction_quote = profiler
12999 .swap_exact_in(U256::from(1_000_000_000_000_000u64), true, None)
13000 .unwrap();
13001 let wrong_direction_out = exact_output_amount(&wrong_direction_quote, true).unwrap();
13002 assert_ne!(
13003 quoted, wrong_direction_out,
13004 "the asymmetric price must make the quote direction observable"
13005 );
13006 assert_ne!(
13007 expected_min_out,
13008 expected_min_amount_out(50),
13009 "the profiler quote must remain distinct from the independent quote fixture"
13010 );
13011
13012 client.submit_order(submit_order_cmd(&order)).unwrap();
13013 await_pending_tasks(&client).await;
13014
13015 let events = collect_order_events(&mut receiver);
13016 assert_eq!(events.len(), 1);
13017 assert!(
13018 matches!(&events[0], OrderEventAny::Submitted(_)),
13019 "was: {:?}",
13020 events[0]
13021 );
13022
13023 let (_, expected_raw) = expected_swap_tx(expected_min_amount_out(50)).await;
13024 let broadcasts: Vec<_> = state
13025 .recorded_requests()
13026 .into_iter()
13027 .filter(|request| request["method"] == "eth_sendRawTransaction")
13028 .collect();
13029 assert_eq!(broadcasts.len(), 1);
13030 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
13031
13032 drop_execution_schema(&admin_pool, &schema).await;
13033 }
13034
13035 #[tokio::test]
13036 async fn submit_order_denies_on_chain_mismatch() {
13037 let state = swap_rpc_state()
13038 .await
13039 .with_response("eth_chainId", CHAIN_ID_ETHEREUM);
13040 let Some((admin_pool, schema, mut client, state, _)) =
13041 swap_client_with_database("execution_submit_chain_mismatch_test", state).await
13042 else {
13043 return;
13044 };
13045 let order = test_market_sell_order(test_pool().instrument_id);
13046 let mut receiver = start_with_events(&mut client);
13047
13048 client.submit_order(submit_order_cmd(&order)).unwrap();
13049 await_pending_tasks(&client).await;
13050
13051 let events = collect_order_events(&mut receiver);
13052 assert_eq!(events.len(), 1);
13053 let OrderEventAny::Denied(denied) = &events[0] else {
13054 panic!("expected OrderDenied, was {:?}", events[0]);
13055 };
13056 assert!(
13057 denied
13058 .reason
13059 .as_str()
13060 .contains("pre-sign chain ID verification disagreed"),
13061 "was: {}",
13062 denied.reason
13063 );
13064 let requests = state.recorded_requests();
13065 assert!(
13066 requests
13067 .iter()
13068 .all(|request| request["method"] != "eth_sendRawTransaction"),
13069 "no broadcast may follow a chain mismatch"
13070 );
13071 assert!(client.in_flight.lock().is_none());
13072
13073 drop_execution_schema(&admin_pool, &schema).await;
13074 }
13075
13076 #[tokio::test]
13077 async fn submit_order_finality_timeout_marks_dropped_and_keeps_ownership() {
13078 let receipt_release = Arc::new(tokio::sync::Semaphore::new(0));
13079 let state = swap_rpc_state()
13080 .await
13081 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
13082 .with_response_release("eth_getTransactionReceipt", Arc::clone(&receipt_release));
13083 let Some((admin_pool, schema, mut client, state, _)) =
13084 swap_client_with_database("execution_submit_inclusion_timeout_test", state).await
13085 else {
13086 return;
13087 };
13088 client.transaction_limits.receipt_timeout_secs = 1;
13089 let order = test_market_sell_order(test_pool().instrument_id);
13090 let mut receiver = start_with_events(&mut client);
13091
13092 client.submit_order(submit_order_cmd(&order)).unwrap();
13093 await_recorded_requests(&state, "eth_getTransactionReceipt", 3).await;
13094 tokio::time::timeout(Duration::from_secs(3), await_pending_tasks(&client))
13095 .await
13096 .unwrap();
13097 receipt_release.add_permits(3);
13098
13099 let events = collect_order_events(&mut receiver);
13102 assert_eq!(events.len(), 1);
13103 assert!(
13104 matches!(&events[0], OrderEventAny::Submitted(_)),
13105 "was: {:?}",
13106 events[0]
13107 );
13108
13109 let record = client
13110 .cache
13111 .get_execution_transaction(
13112 42161,
13113 &expected_swap_tx(expected_min_amount_out(50))
13114 .await
13115 .0
13116 .to_string(),
13117 )
13118 .await
13119 .unwrap()
13120 .unwrap();
13121 assert_eq!(record.status, "dropped");
13122 assert!(client.in_flight.lock().is_some());
13123
13124 drop_execution_schema(&admin_pool, &schema).await;
13125 }
13126
13127 #[tokio::test]
13128 async fn included_receipt_finality_timeout_marks_dropped_and_keeps_ownership() {
13129 let state = execution_rpc_state()
13130 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
13131 .with_response("eth_estimateGas", ESTIMATE_GAS)
13132 .with_response("eth_call", CALL_BALANCE)
13133 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS)
13134 .with_parameter_response("eth_getBlockByNumber", "finalized", BLOCK_BY_NUMBER)
13135 .with_send_raw_transaction_echo();
13136 let Some((admin_pool, schema, mut client, _)) =
13137 execution_client_with_database("execution_included_timeout_test", state).await
13138 else {
13139 return;
13140 };
13141 client.transaction_limits.receipt_timeout_secs = 1;
13142
13143 let error = client
13144 .wrap(U256::from(1_000_000_000_000_000_u64))
13145 .await
13146 .unwrap_err();
13147 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
13148 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
13149 )))
13150 .fetch_all(&admin_pool)
13151 .await
13152 .unwrap();
13153
13154 assert!(
13155 error.to_string().contains("Timed out awaiting finality"),
13156 "was: {error}"
13157 );
13158 assert_eq!(transitions, ["prepared", "signed", "broadcast", "dropped"]);
13159 assert!(client.in_flight.lock().is_some());
13160
13161 drop_execution_schema(&admin_pool, &schema).await;
13162 }
13163
13164 #[tokio::test]
13165 async fn submit_order_single_in_flight_rejects_concurrent_swap() {
13166 let broadcast_release = Arc::new(tokio::sync::Semaphore::new(0));
13167 let state = swap_rpc_state()
13168 .await
13169 .with_response_release("eth_sendRawTransaction", Arc::clone(&broadcast_release));
13170 let Some((admin_pool, schema, mut client, state, cache)) =
13171 swap_client_with_database("execution_submit_concurrent_test", state).await
13172 else {
13173 return;
13174 };
13175 let pool = test_pool();
13176 let first = test_market_sell_order(pool.instrument_id);
13177 let second = OrderTestBuilder::new(OrderType::Market)
13178 .trader_id(TraderId::from("TRADER-001"))
13179 .strategy_id(StrategyId::from("S-001"))
13180 .instrument_id(pool.instrument_id)
13181 .client_order_id(ClientOrderId::from("O-SWAP-002"))
13182 .side(OrderSide::Sell)
13183 .quantity(Quantity::from("0.001"))
13184 .build();
13185 cache
13186 .borrow_mut()
13187 .add_order(second.clone(), None, None, false)
13188 .unwrap();
13189 let mut receiver = start_with_events(&mut client);
13190
13191 client.submit_order(submit_order_cmd(&first)).unwrap();
13192 await_recorded_requests(&state, "eth_sendRawTransaction", 1).await;
13193 client.submit_order(submit_order_cmd(&second)).unwrap();
13194 let event = tokio::time::timeout(TEST_TIMEOUT, receiver.recv())
13195 .await
13196 .unwrap()
13197 .unwrap();
13198 let ExecutionEvent::Order(OrderEventAny::Denied(denied)) = event else {
13199 panic!("expected OrderDenied, was {event:?}");
13200 };
13201 assert_eq!(denied.client_order_id, second.client_order_id());
13202 assert!(
13203 denied
13204 .reason
13205 .as_str()
13206 .contains("at most one transaction can be in flight"),
13207 "was: {}",
13208 denied.reason
13209 );
13210 broadcast_release.add_permits(1);
13211 await_pending_tasks(&client).await;
13212
13213 let events = collect_order_events(&mut receiver);
13214 assert_swap_submitted_and_filled(&events);
13215
13216 let broadcasts = state
13217 .recorded_requests()
13218 .into_iter()
13219 .filter(|request| request["method"] == "eth_sendRawTransaction")
13220 .count();
13221 assert_eq!(broadcasts, 1);
13222 let row_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
13223 "SELECT COUNT(*) FROM {schema}.execution_intent"
13224 )))
13225 .fetch_one(&admin_pool)
13226 .await
13227 .unwrap();
13228 assert_eq!(row_count, 1);
13229 assert!(client.in_flight.lock().is_none());
13230
13231 drop_execution_schema(&admin_pool, &schema).await;
13232 }
13233
13234 #[tokio::test]
13235 async fn preflight_ready_when_all_checks_pass() {
13236 let (client, state) = client_with_mock_rpc(ready_rpc_state()).await;
13237 let pool = test_pool();
13238
13239 let report = client.preflight(&pool.instrument_id).await.unwrap();
13240
13241 assert!(report.ready, "issues: {:?}", report.issues);
13242 assert!(report.issues.is_empty());
13243 assert_eq!(report.expected_chain_id, 42161);
13244 assert_eq!(report.actual_chain_id, 42161);
13245 assert!(report.chain_id_matches);
13246 assert_eq!(
13247 report.pool.address,
13248 address!("C6962004f452bE9203591991D15f6b388e09E8D0")
13249 );
13250 assert!(report.pool.has_deployed_code);
13251 assert_eq!(report.pool.fee, Some(500));
13252 assert_eq!(
13253 report.pool.base_token,
13254 address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1")
13255 );
13256 assert_eq!(
13257 report.pool.quote_token,
13258 address!("af88d065e77c8cC2239327C5EDb3A432268e5831")
13259 );
13260 assert_eq!(report.routers.len(), 1);
13261 assert!(report.routers[0].has_deployed_code);
13262 assert_eq!(report.tokens.len(), 2);
13263 assert_eq!(
13264 report.tokens[0].wallet_balance,
13265 U256::from(500_000_000_000_000_000u64)
13266 );
13267 assert_eq!(
13268 report.tokens[0].router_allowances,
13269 vec![(
13270 address!("E592427A0AEce92De3Edee1F18E0157C05861564"),
13271 U256::from(1_000_000_000_000_000_000u64)
13272 )]
13273 );
13274 assert_eq!(
13275 report.native_balance_wei,
13276 U256::from(1_000_000_000_000_000_000u64)
13277 );
13278 assert_eq!(report.base_fee_per_gas_wei, 100_000_000);
13279 assert_eq!(report.max_priority_fee_per_gas_wei, 10_000_000);
13280 assert_eq!(report.derived_max_fee_per_gas_wei, 130_000_000);
13281 assert!(report.fee_within_ceiling);
13282
13283 let requests = state.recorded_requests();
13284 for method in ["eth_getCode", "eth_call", "eth_getBalance"] {
13285 let matching: Vec<_> = requests
13286 .iter()
13287 .filter(|request| request["method"] == method)
13288 .collect();
13289 assert!(!matching.is_empty(), "method {method}");
13290 assert!(
13291 matching
13292 .iter()
13293 .all(|request| request["params"][1] == "latest"),
13294 "method {method}: {matching:?}"
13295 );
13296 }
13297 }
13298
13299 #[tokio::test]
13300 async fn preflight_not_ready_without_pool_fee() {
13301 let addr = start_mock_rpc_server(ready_rpc_state()).await;
13302 let mut pool = test_pool();
13303 pool.fee = None;
13304 let client = test_client_from_config(test_config(format!("http://{addr}")), pool.clone());
13305
13306 let report = client.preflight(&pool.instrument_id).await.unwrap();
13307
13308 assert!(!report.ready);
13309 assert_eq!(report.pool.fee, None);
13310 assert_eq!(report.issues, vec!["Pool fee tier is missing"]);
13311 }
13312
13313 #[tokio::test]
13314 async fn preflight_not_ready_on_wrong_chain() {
13315 let state = ready_rpc_state().with_response("eth_chainId", CHAIN_ID_ETHEREUM);
13316 let (client, _) = client_with_mock_rpc(state).await;
13317 let pool = test_pool();
13318
13319 let report = client.preflight(&pool.instrument_id).await.unwrap();
13320
13321 assert!(!report.ready);
13322 assert!(!report.chain_id_matches);
13323 assert!(
13324 report
13325 .issues
13326 .iter()
13327 .any(|issue| issue.contains("Chain ID mismatch"))
13328 );
13329 }
13330
13331 #[tokio::test]
13332 async fn preflight_not_ready_without_deployed_code() {
13333 let state = ready_rpc_state().with_response("eth_getCode", GET_CODE_EMPTY);
13334 let (client, _) = client_with_mock_rpc(state).await;
13335 let pool = test_pool();
13336
13337 let report = client.preflight(&pool.instrument_id).await.unwrap();
13338
13339 assert!(!report.ready);
13340 assert!(!report.pool.has_deployed_code);
13341 assert!(!report.routers[0].has_deployed_code);
13342 assert!(report.tokens.iter().all(|t| !t.has_deployed_code));
13343 assert!(
13344 report
13345 .issues
13346 .iter()
13347 .any(|issue| issue.contains("No deployed bytecode at router address"))
13348 );
13349 }
13350
13351 #[tokio::test]
13352 async fn preflight_not_ready_with_zero_balances_and_allowance() {
13353 let state = ready_rpc_state()
13354 .with_response("eth_getBalance", GET_BALANCE_ZERO)
13355 .with_call_response(BALANCE_OF_SELECTOR, CALL_ZERO)
13356 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO);
13357 let (client, _) = client_with_mock_rpc(state).await;
13358 let pool = test_pool();
13359
13360 let report = client.preflight(&pool.instrument_id).await.unwrap();
13361
13362 assert!(!report.ready);
13363 assert!(
13364 report
13365 .issues
13366 .iter()
13367 .any(|issue| issue.contains("Native currency balance is zero"))
13368 );
13369 assert!(
13370 report
13371 .issues
13372 .iter()
13373 .any(|issue| issue.contains("balance is zero"))
13374 );
13375 assert!(
13376 report
13377 .issues
13378 .iter()
13379 .any(|issue| issue.contains("No router allowance"))
13380 );
13381 }
13382
13383 #[tokio::test]
13384 async fn preflight_not_ready_when_fees_exceed_ceiling() {
13385 let addr = start_mock_rpc_server(ready_rpc_state()).await;
13386 let mut config = test_config(format!("http://{addr}"));
13387 config.max_fee_per_gas_wei = 1;
13388 let pool = test_pool();
13389 let client = test_client_from_config(config, pool.clone());
13390
13391 let report = client.preflight(&pool.instrument_id).await.unwrap();
13392
13393 assert!(!report.ready);
13394 assert!(!report.fee_within_ceiling);
13395 assert_eq!(report.derived_max_fee_per_gas_wei, 130_000_000);
13396 assert!(
13397 report
13398 .issues
13399 .iter()
13400 .any(|issue| issue.contains("exceeds ceiling"))
13401 );
13402 }
13403
13404 #[rstest]
13405 fn resolve_pool_rejects_unknown_pool() {
13406 let client = test_client("http://127.0.0.1:1".to_string());
13407 let unknown: InstrumentId = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45.Arbitrum:UniswapV3"
13408 .parse()
13409 .unwrap();
13410
13411 let error = client.resolve_pool(&unknown).unwrap_err();
13412
13413 assert!(error.to_string().contains("Unknown pool"), "was: {error}");
13414 }
13415
13416 #[rstest]
13417 fn resolve_pool_rejects_mismatched_chain() {
13418 let client = test_client("http://127.0.0.1:1".to_string());
13419 let ethereum_pool: InstrumentId =
13420 "0xC6962004f452bE9203591991D15f6b388e09E8D0.Ethereum:UniswapV3"
13421 .parse()
13422 .unwrap();
13423
13424 let error = client.resolve_pool(ðereum_pool).unwrap_err();
13425
13426 assert!(
13427 error
13428 .to_string()
13429 .contains("does not match the client chain"),
13430 "was: {error}"
13431 );
13432 }
13433
13434 #[rstest]
13435 fn resolve_pool_rejects_unsupported_dex() {
13436 let client = test_client("http://127.0.0.1:1".to_string());
13437 let v4_pool: InstrumentId = "0xC6962004f452bE9203591991D15f6b388e09E8D0.Arbitrum:UniswapV4"
13438 .parse()
13439 .unwrap();
13440
13441 let error = client.resolve_pool(&v4_pool).unwrap_err();
13442
13443 assert!(
13444 error.to_string().contains("only UniswapV3 is supported"),
13445 "was: {error}"
13446 );
13447 }
13448
13449 #[rstest]
13450 fn resolve_pool_rejects_pool_id_identifier() {
13451 let client = test_client("http://127.0.0.1:1".to_string());
13452 let pool_id: InstrumentId =
13453 "0x0000000000000000000000000000000000000000000000000000000000000000.Arbitrum:UniswapV3"
13454 .parse()
13455 .unwrap();
13456
13457 let error = client.resolve_pool(&pool_id).unwrap_err();
13458
13459 assert!(
13460 error
13461 .to_string()
13462 .contains("only address identifiers are supported"),
13463 "was: {error}"
13464 );
13465 }
13466
13467 #[rstest]
13468 fn resolve_pool_rejects_ambiguous_token_priority() {
13469 let chain = Arc::new(chains::ARBITRUM.clone());
13470 let dex = UNISWAP_V3.dex.clone();
13471 let token_a = Token::new(
13472 chain.clone(),
13473 address!("1111111111111111111111111111111111111111"),
13474 "Token A".to_string(),
13475 "TOKA".to_string(),
13476 18,
13477 );
13478 let token_b = Token::new(
13479 chain.clone(),
13480 address!("2222222222222222222222222222222222222222"),
13481 "Token B".to_string(),
13482 "TOKB".to_string(),
13483 18,
13484 );
13485 let pool = Pool::new(
13486 chain,
13487 dex,
13488 address!("3333333333333333333333333333333333333333"),
13489 PoolIdentifier::from_address(address!("3333333333333333333333333333333333333333")),
13490 55_000_000,
13491 token_a,
13492 token_b,
13493 Some(500),
13494 Some(10),
13495 UnixNanos::default(),
13496 );
13497 let client =
13498 test_client_from_config(test_config("http://127.0.0.1:1".to_string()), pool.clone());
13499
13500 let error = client.resolve_pool(&pool.instrument_id).unwrap_err();
13501
13502 assert!(error.to_string().contains("ambiguous"), "was: {error}");
13503 }
13504
13505 #[rstest]
13506 fn new_parses_pair_specific_quote_spend_limits_with_distinct_precisions() {
13507 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13508 config.quote_spend_limits = Some(vec![
13509 quote_spend_limit(WETH, USDC, 18, &U256::MAX.to_string()),
13510 quote_spend_limit(USDC, WETH, 6, "1000000000"),
13511 ]);
13512
13513 let client = test_client_from_config(config, test_pool());
13514 let sell_ceiling = client
13515 .transaction_limits
13516 .quote_spend_limits
13517 .get(&(WETH_ADDRESS, USDC_ADDRESS))
13518 .unwrap();
13519 let buy_ceiling = client
13520 .transaction_limits
13521 .quote_spend_limits
13522 .get(&(USDC_ADDRESS, WETH_ADDRESS))
13523 .unwrap();
13524
13525 assert_eq!(sell_ceiling.spend_token, WETH_ADDRESS);
13526 assert_eq!(sell_ceiling.spend_token_decimals, 18);
13527 assert_eq!(sell_ceiling.max_amount, U256::MAX);
13528 assert_eq!(buy_ceiling.spend_token, USDC_ADDRESS);
13529 assert_eq!(buy_ceiling.spend_token_decimals, 6);
13530 assert_eq!(buy_ceiling.max_amount, U256::from(1_000_000_000u64));
13531 }
13532
13533 #[rstest]
13534 fn new_rejects_quote_spend_limit_token_pair_mismatch() {
13535 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13536 config.quote_spend_limits.as_mut().unwrap()[0].spend_token = WETH.to_string();
13537
13538 let error = test_client_result(config, test_pool()).unwrap_err();
13539
13540 assert!(
13541 error
13542 .to_string()
13543 .contains("`spend_token` must match `token_in`"),
13544 "was: {error}"
13545 );
13546 }
13547
13548 #[rstest]
13549 fn new_rejects_quote_spend_limit_pair_outside_allowlist() {
13550 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13551 config.quote_spend_limits = Some(vec![quote_spend_limit(
13552 USDC,
13553 "0x1111111111111111111111111111111111111111",
13554 6,
13555 "1000000000",
13556 )]);
13557
13558 let error = test_client_result(config, test_pool()).unwrap_err();
13559
13560 assert!(
13561 error
13562 .to_string()
13563 .contains("is not in the `allowed_token_pairs` allowlist"),
13564 "was: {error}"
13565 );
13566 }
13567
13568 #[rstest]
13569 fn new_rejects_duplicate_quote_spend_pairs() {
13570 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13571 config.quote_spend_limits = Some(vec![
13572 quote_spend_limit(USDC, WETH, 6, "1000000000"),
13573 quote_spend_limit(USDC, WETH, 6, "2000000000"),
13574 ]);
13575
13576 let error = test_client_result(config, test_pool()).unwrap_err();
13577
13578 assert!(
13579 error
13580 .to_string()
13581 .contains("Duplicate quote spend limit for token pair"),
13582 "was: {error}"
13583 );
13584 }
13585
13586 #[rstest]
13587 #[case::empty("")]
13588 #[case::signed("-1")]
13589 #[case::fractional("1.5")]
13590 #[case::hexadecimal("0x10")]
13591 #[case::overflow(
13592 "115792089237316195423570985008687907853269984665640564039457584007913129639936"
13593 )]
13594 fn new_rejects_invalid_quote_spend_max_amount(#[case] max_amount: &str) {
13595 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13596 config.quote_spend_limits.as_mut().unwrap()[0].max_amount = max_amount.to_string();
13597
13598 let error = test_client_result(config, test_pool()).unwrap_err();
13599
13600 assert!(
13601 error.to_string().contains("Quote spend limit `max_amount`"),
13602 "was: {error}"
13603 );
13604 }
13605
13606 #[rstest]
13607 #[case::allowed_token_pairs("allowed_token_pairs")]
13608 #[case::slippage_bps("slippage_bps")]
13609 #[case::max_slippage_bps("max_slippage_bps")]
13610 #[case::max_order_amount("max_order_amount")]
13611 #[case::deadline_seconds("deadline_seconds")]
13612 #[case::max_quote_age_blocks("max_quote_age_blocks")]
13613 #[case::receipt_timeout_secs("receipt_timeout_secs")]
13614 fn new_rejects_each_missing_transaction_limit(#[case] missing: &str) {
13615 let mut config = test_config("http://127.0.0.1:1".to_string());
13616 match missing {
13617 "allowed_token_pairs" => config.allowed_token_pairs = None,
13618 "slippage_bps" => config.slippage_bps = None,
13619 "max_slippage_bps" => config.max_slippage_bps = None,
13620 "max_order_amount" => config.max_order_amount = None,
13621 "deadline_seconds" => config.deadline_seconds = None,
13622 "max_quote_age_blocks" => config.max_quote_age_blocks = None,
13623 "receipt_timeout_secs" => config.receipt_timeout_secs = None,
13624 _ => unreachable!(),
13625 }
13626
13627 let error = test_client_result(config, test_pool()).unwrap_err();
13628
13629 assert_eq!(
13630 error.to_string(),
13631 "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"
13632 );
13633 }
13634
13635 #[rstest]
13636 fn new_rejects_empty_router_allowlist() {
13637 let mut config = test_config("http://127.0.0.1:1".to_string());
13638 config.router_addresses = Vec::new();
13639
13640 let error = test_client_result(config, test_pool()).unwrap_err();
13641
13642 assert!(
13643 error.to_string().contains("at least one router address"),
13644 "was: {error}"
13645 );
13646 }
13647
13648 #[tokio::test]
13649 async fn wrap_refuses_without_durable_store() {
13650 let (mut client, _) = client_with_mock_rpc(ready_rpc_state()).await;
13651 client.core.set_connected();
13652
13653 let error = client.wrap(U256::from(1_000u64)).await.unwrap_err();
13654
13655 assert!(
13656 error.to_string().contains("No durable store configured"),
13657 "was: {error}"
13658 );
13659 }
13660
13661 #[tokio::test]
13662 async fn approve_rejects_router_outside_allowlist() {
13663 let (mut client, state) = client_with_mock_rpc(ready_rpc_state()).await;
13664
13665 let error = client
13666 .approve(
13667 WETH_ADDRESS,
13668 U256::from(1_000u64),
13669 address!("68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"),
13670 )
13671 .await
13672 .err()
13673 .unwrap();
13674
13675 assert!(
13676 error
13677 .to_string()
13678 .contains("not in the configured `router_addresses` allowlist"),
13679 "was: {error}"
13680 );
13681 assert!(state.recorded_requests().is_empty());
13682 }
13683
13684 #[tokio::test]
13685 async fn approve_rejects_token_outside_input_allowlist() {
13686 let (mut client, state) = client_with_mock_rpc(ready_rpc_state()).await;
13687
13688 let error = client
13689 .approve(USDC_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
13690 .await
13691 .unwrap_err();
13692
13693 assert!(
13694 error
13695 .to_string()
13696 .contains("is not an input token in the configured `allowed_token_pairs`"),
13697 "was: {error}"
13698 );
13699 assert!(state.recorded_requests().is_empty());
13700 }
13701
13702 #[tokio::test]
13703 async fn in_flight_guard_rejects_second_transaction() {
13704 let (mut client, _) = client_with_mock_rpc(ready_rpc_state()).await;
13705 client.core.set_connected();
13706 *client.in_flight.lock() = Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
13707 intent_id: 1,
13708 nonce: 7,
13709 tx_hash: B256::ZERO,
13710 purpose: TransactionPurpose::Wrap,
13711 }));
13712
13713 let error = client.wrap(U256::from(1_000u64)).await.unwrap_err();
13714
13715 assert!(
13716 error.to_string().contains("still awaiting finality"),
13717 "was: {error}"
13718 );
13719 }
13720
13721 #[tokio::test]
13722 async fn wrap_rejects_zero_amount() {
13723 let (mut client, _) = client_with_mock_rpc(ready_rpc_state()).await;
13724
13725 let error = client.wrap(U256::ZERO).await.unwrap_err();
13726
13727 assert!(
13728 error.to_string().contains("Wrap amount must be positive"),
13729 "was: {error}"
13730 );
13731 }
13732
13733 #[tokio::test]
13734 async fn wrap_rejects_code_free_target_before_broadcast() {
13735 let state = execution_rpc_state().with_response("eth_getCode", GET_CODE_EMPTY);
13736 let Some((admin_pool, schema, mut client, state)) =
13737 execution_client_with_database("execution_wrap_code_free_test", state).await
13738 else {
13739 return;
13740 };
13741
13742 let error = client
13743 .wrap(U256::from(1_000_000_000_000_000u64))
13744 .await
13745 .unwrap_err();
13746
13747 assert!(
13748 error
13749 .to_string()
13750 .contains("pre-sign deployment manifest verification disagreed"),
13751 "was: {error}"
13752 );
13753 assert!(client.in_flight.lock().is_none());
13754 let requests = state.recorded_requests();
13755 assert_eq!(
13756 requests
13757 .iter()
13758 .filter(|request| request["method"] == "eth_getCode")
13759 .count(),
13760 3
13761 );
13762 assert!(
13763 requests
13764 .iter()
13765 .all(|request| request["method"] != "eth_sendRawTransaction")
13766 );
13767
13768 drop_execution_schema(&admin_pool, &schema).await;
13769 }
13770
13771 #[tokio::test]
13772 async fn wrap_rejects_unrelated_target_before_broadcast() {
13773 let state = execution_rpc_state().with_response("eth_call", CALL_EMPTY);
13774 let Some((admin_pool, schema, mut client, state)) =
13775 execution_client_with_database("execution_wrap_unrelated_test", state).await
13776 else {
13777 return;
13778 };
13779
13780 let error = client
13781 .wrap(U256::from(1_000_000_000_000_000u64))
13782 .await
13783 .unwrap_err();
13784
13785 assert!(
13786 error
13787 .to_string()
13788 .contains("pre-sign wrapped token probe verification is unavailable"),
13789 "was: {error}"
13790 );
13791 assert!(client.in_flight.lock().is_none());
13792 let requests = state.recorded_requests();
13793 assert!(
13794 requests
13795 .iter()
13796 .all(|request| request["method"] != "eth_sendRawTransaction")
13797 );
13798
13799 drop_execution_schema(&admin_pool, &schema).await;
13800 }
13801
13802 #[tokio::test]
13803 async fn wrap_rejects_included_transaction_without_balance_delta() {
13804 let state = broadcast_rpc_state().with_response_sequence("eth_call", &[CALL_BALANCE; 9]);
13805 let Some((admin_pool, schema, mut client, state)) =
13806 execution_client_with_database("execution_wrap_no_delta_test", state).await
13807 else {
13808 return;
13809 };
13810
13811 let error = client
13812 .wrap(U256::from(1_000_000_000_000_000u64))
13813 .await
13814 .unwrap_err();
13815
13816 assert!(
13817 error.to_string().contains("did not increase"),
13818 "was: {error}"
13819 );
13820 let in_flight = awaiting_in_flight(&client);
13821 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
13822 assert_eq!(
13823 execution_intent_markers(&admin_pool, &schema).await,
13824 vec![("wrap".into(), "broadcast".into(), false, true)]
13825 );
13826 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
13827 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
13828 )))
13829 .fetch_one(&admin_pool)
13830 .await
13831 .unwrap();
13832 assert_eq!(nonce_state, (7, 0));
13833 let broadcasts = state
13834 .recorded_requests()
13835 .into_iter()
13836 .filter(|request| request["method"] == "eth_sendRawTransaction")
13837 .count();
13838 assert_eq!(broadcasts, 1);
13839
13840 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000u64)).await;
13841 let block = finalized_wrap_block(expected_hash);
13842 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
13843 let restart_state = with_finalized_identity(
13844 execution_rpc_state()
13845 .with_response("eth_getTransactionReceipt", &receipt)
13846 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
13847 .with_response_sequence("eth_call", &[CALL_BALANCE; 6]),
13848 &block,
13849 &receipt,
13850 );
13851 let addr = start_mock_rpc_server(restart_state).await;
13852 let error = later_reconnect(client, format!("http://{addr}")).await;
13853 assert!(
13854 error.to_string().contains("did not increase"),
13855 "was: {error}"
13856 );
13857 assert_eq!(
13858 execution_intent_markers(&admin_pool, &schema).await,
13859 vec![("wrap".into(), "broadcast".into(), false, true)]
13860 );
13861 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
13862 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
13863 )))
13864 .fetch_one(&admin_pool)
13865 .await
13866 .unwrap();
13867 assert_eq!(nonce_state, (7, 0));
13868
13869 drop_execution_schema(&admin_pool, &schema).await;
13870 }
13871
13872 #[tokio::test]
13873 async fn wrap_reports_inclusion_when_postcondition_read_fails() {
13874 let state = broadcast_rpc_state().with_response_sequence(
13875 "eth_call",
13876 &[
13877 CALL_BALANCE,
13878 CALL_BALANCE,
13879 CALL_BALANCE,
13880 CALL_BALANCE,
13881 CALL_BALANCE,
13882 CALL_BALANCE,
13883 RPC_METHOD_NOT_FOUND,
13884 RPC_METHOD_NOT_FOUND,
13885 RPC_METHOD_NOT_FOUND,
13886 ],
13887 );
13888 let Some((admin_pool, schema, mut client, _)) =
13889 execution_client_with_database("execution_wrap_postcondition_rpc_test", state).await
13890 else {
13891 return;
13892 };
13893
13894 let error = client
13895 .wrap(U256::from(1_000_000_000_000_000u64))
13896 .await
13897 .unwrap_err();
13898
13899 let message = error.to_string();
13900 assert!(
13901 message.contains("failed to verify WETH balance after included transaction 0x"),
13902 "was: {message}"
13903 );
13904 assert!(message.contains("at block 30346561"), "was: {message}");
13905 let in_flight = awaiting_in_flight(&client);
13906 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
13907 assert_eq!(
13908 execution_intent_markers(&admin_pool, &schema).await,
13909 vec![("wrap".into(), "broadcast".into(), false, true)]
13910 );
13911
13912 drop_execution_schema(&admin_pool, &schema).await;
13913 }
13914
13915 #[tokio::test]
13916 async fn approve_rejects_false_return_before_broadcast() {
13917 let state = execution_rpc_state().with_response("eth_call", CALL_ZERO);
13918 let Some((admin_pool, schema, mut client, state)) =
13919 execution_client_with_database("execution_approve_false_test", state).await
13920 else {
13921 return;
13922 };
13923
13924 let error = client
13925 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
13926 .await
13927 .unwrap_err();
13928
13929 assert!(error.to_string().contains("returned false"), "was: {error}");
13930 assert!(client.in_flight.lock().is_none());
13931 let requests = state.recorded_requests();
13932 let approval_calls = requests
13933 .iter()
13934 .filter(|request| {
13935 request["method"] == "eth_call"
13936 && request["params"][0]["data"]
13937 .as_str()
13938 .is_some_and(|data| data.starts_with("0x095ea7b3"))
13939 })
13940 .collect::<Vec<_>>();
13941 assert_eq!(approval_calls.len(), 3);
13942 for request in approval_calls {
13943 assert_eq!(
13944 request["params"][0]["from"]
13945 .as_str()
13946 .unwrap()
13947 .parse::<Address>()
13948 .unwrap(),
13949 WALLET.parse::<Address>().unwrap()
13950 );
13951 assert_eq!(request["params"][1], FIXTURE_BLOCK_PARAM);
13952 }
13953 assert!(
13954 requests
13955 .iter()
13956 .all(|request| request["method"] != "eth_getTransactionCount")
13957 );
13958 assert!(
13959 requests
13960 .iter()
13961 .all(|request| request["method"] != "eth_sendRawTransaction")
13962 );
13963
13964 drop_execution_schema(&admin_pool, &schema).await;
13965 }
13966
13967 #[tokio::test]
13968 async fn approve_rejects_router_with_wrong_factory_before_signing() {
13969 let state = ready_rpc_state().with_call_response(FACTORY_SELECTOR, CALL_ZERO);
13970 let Some((admin_pool, schema, mut client, state)) =
13971 execution_client_with_database("execution_approve_factory_test", state).await
13972 else {
13973 return;
13974 };
13975
13976 let error = client
13977 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
13978 .await
13979 .unwrap_err();
13980
13981 assert!(
13982 error
13983 .to_string()
13984 .contains("pre-sign deployment manifest verification disagreed"),
13985 "was: {error}"
13986 );
13987 let requests = state.recorded_requests();
13988 assert!(
13989 requests
13990 .iter()
13991 .all(|request| request["method"] != "eth_getTransactionCount")
13992 );
13993 assert!(
13994 requests
13995 .iter()
13996 .all(|request| request["method"] != "eth_sendRawTransaction")
13997 );
13998
13999 drop_execution_schema(&admin_pool, &schema).await;
14000 }
14001
14002 #[tokio::test]
14003 async fn approve_rejects_router_with_wrong_weth_before_signing() {
14004 let state = ready_rpc_state().with_call_response(WETH9_SELECTOR, CALL_ZERO);
14005 let Some((admin_pool, schema, mut client, state)) =
14006 execution_client_with_database("execution_approve_weth_test", state).await
14007 else {
14008 return;
14009 };
14010
14011 let error = client
14012 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
14013 .await
14014 .unwrap_err();
14015
14016 assert!(
14017 error
14018 .to_string()
14019 .contains("pre-sign deployment manifest verification disagreed"),
14020 "was: {error}"
14021 );
14022 let requests = state.recorded_requests();
14023 assert!(
14024 requests
14025 .iter()
14026 .all(|request| request["method"] != "eth_getTransactionCount")
14027 );
14028 assert!(
14029 requests
14030 .iter()
14031 .all(|request| request["method"] != "eth_sendRawTransaction")
14032 );
14033
14034 drop_execution_schema(&admin_pool, &schema).await;
14035 }
14036
14037 #[tokio::test]
14038 async fn approve_rejects_nonzero_to_nonzero_transition_before_signing() {
14039 let state = ready_rpc_state();
14040 let Some((admin_pool, schema, mut client, state)) =
14041 execution_client_with_database("execution_approve_nonzero_test", state).await
14042 else {
14043 return;
14044 };
14045
14046 let error = client
14047 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
14048 .await
14049 .unwrap_err();
14050
14051 assert!(
14052 error.to_string().contains("approve zero before setting"),
14053 "was: {error}"
14054 );
14055 let requests = state.recorded_requests();
14056 assert!(
14057 requests
14058 .iter()
14059 .all(|request| request["method"] != "eth_getTransactionCount")
14060 );
14061 assert!(
14062 requests
14063 .iter()
14064 .all(|request| request["method"] != "eth_sendRawTransaction")
14065 );
14066
14067 drop_execution_schema(&admin_pool, &schema).await;
14068 }
14069
14070 #[tokio::test]
14071 async fn approve_zero_revokes_under_unlimited_policy() {
14072 let state = broadcast_rpc_state()
14073 .with_response("eth_call", CALL_BOOL_TRUE)
14074 .with_call_response_sequence(
14075 ALLOWANCE_SELECTOR,
14076 &[
14077 CALL_ALLOWANCE,
14078 CALL_ALLOWANCE,
14079 CALL_ALLOWANCE,
14080 CALL_ZERO,
14081 CALL_ZERO,
14082 CALL_ZERO,
14083 ],
14084 );
14085 let Some((admin_pool, schema, mut client, state)) =
14086 execution_client_with_database("execution_approve_revoke_test", state).await
14087 else {
14088 return;
14089 };
14090 client.config.unlimited_approval = true;
14091
14092 let tx_hash = client
14093 .approve(WETH_ADDRESS, U256::ZERO, ROUTER_ADDRESS)
14094 .await
14095 .unwrap();
14096
14097 assert_eq!(tx_hash, expected_approve_tx_hash(U256::ZERO).await);
14098 let approve_data = state
14099 .recorded_requests()
14100 .into_iter()
14101 .find_map(|request| {
14102 (request["method"] == "eth_estimateGas")
14103 .then(|| request["params"][0]["data"].as_str().map(str::to_owned))
14104 .flatten()
14105 })
14106 .unwrap();
14107 assert!(approve_data.starts_with("0x095ea7b3"));
14108 assert!(approve_data.ends_with(&"0".repeat(64)));
14109 assert_eq!(
14110 state
14111 .recorded_requests()
14112 .iter()
14113 .filter(|request| {
14114 request["method"] == "eth_call"
14115 && request["params"][0]["data"]
14116 .as_str()
14117 .is_some_and(|data| data.starts_with(FACTORY_SELECTOR))
14118 })
14119 .count(),
14120 12
14121 );
14122 assert_eq!(
14123 execution_intent_markers(&admin_pool, &schema).await,
14124 vec![("approve".into(), "finalized".into(), true, false)]
14125 );
14126
14127 drop_execution_schema(&admin_pool, &schema).await;
14128 }
14129
14130 #[tokio::test]
14131 async fn approve_accepts_empty_return_with_sufficient_allowance() {
14132 let state = broadcast_rpc_state()
14133 .with_response("eth_call", CALL_EMPTY)
14134 .with_call_response_sequence(
14135 ALLOWANCE_SELECTOR,
14136 &[
14137 CALL_ZERO,
14138 CALL_ZERO,
14139 CALL_ZERO,
14140 CALL_ALLOWANCE_1000,
14141 CALL_ALLOWANCE_1000,
14142 CALL_ALLOWANCE_1000,
14143 ],
14144 );
14145 let Some((admin_pool, schema, mut client, _)) =
14146 execution_client_with_database("execution_approve_empty_test", state).await
14147 else {
14148 return;
14149 };
14150
14151 let tx_hash = client
14152 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
14153 .await
14154 .unwrap();
14155
14156 let record = client
14157 .cache
14158 .get_execution_transaction(42161, &tx_hash.to_string())
14159 .await
14160 .unwrap()
14161 .unwrap();
14162 assert_eq!(record.purpose, "approve");
14163 assert_eq!(record.status, "finalized");
14164 assert!(client.in_flight.lock().is_none());
14165 assert_eq!(
14166 execution_intent_markers(&admin_pool, &schema).await,
14167 vec![("approve".into(), "finalized".into(), true, false)]
14168 );
14169
14170 drop_execution_schema(&admin_pool, &schema).await;
14171 }
14172
14173 #[tokio::test]
14174 async fn approve_rejects_empty_return_with_insufficient_allowance() {
14175 let state = broadcast_rpc_state()
14176 .with_response("eth_call", CALL_EMPTY)
14177 .with_call_response_sequence(ALLOWANCE_SELECTOR, &[CALL_ZERO; 6]);
14178 let Some((admin_pool, schema, mut client, _)) =
14179 execution_client_with_database("execution_approve_insufficient_test", state).await
14180 else {
14181 return;
14182 };
14183
14184 let error = client
14185 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
14186 .await
14187 .unwrap_err();
14188
14189 assert!(
14190 error
14191 .to_string()
14192 .contains("does not equal the requested amount"),
14193 "was: {error}"
14194 );
14195 let in_flight = awaiting_in_flight(&client);
14196 assert_eq!(in_flight.purpose, TransactionPurpose::Approve);
14197 assert_eq!(
14198 execution_intent_markers(&admin_pool, &schema).await,
14199 vec![("approve".into(), "broadcast".into(), false, true)]
14200 );
14201 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
14202 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
14203 )))
14204 .fetch_one(&admin_pool)
14205 .await
14206 .unwrap();
14207 assert_eq!(nonce_state, (7, 0));
14208
14209 let expected_hash = expected_approve_tx_hash(U256::from(1_000u64)).await;
14210 let block = finalized_approve_block(expected_hash, U256::from(1_000u64));
14211 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
14212 let restart_state = with_finalized_identity(
14213 execution_rpc_state()
14214 .with_response("eth_getTransactionReceipt", &receipt)
14215 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
14216 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO),
14217 &block,
14218 &receipt,
14219 );
14220 let addr = start_mock_rpc_server(restart_state).await;
14221 let error = later_reconnect(client, format!("http://{addr}")).await;
14222 assert!(
14223 error
14224 .to_string()
14225 .contains("does not equal the requested amount"),
14226 "was: {error}"
14227 );
14228 assert_eq!(
14229 execution_intent_markers(&admin_pool, &schema).await,
14230 vec![("approve".into(), "broadcast".into(), false, true)]
14231 );
14232 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
14233 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
14234 )))
14235 .fetch_one(&admin_pool)
14236 .await
14237 .unwrap();
14238 assert_eq!(nonce_state, (7, 0));
14239
14240 drop_execution_schema(&admin_pool, &schema).await;
14241 }
14242
14243 #[tokio::test]
14244 async fn approve_reports_inclusion_when_postcondition_read_fails() {
14245 let state = broadcast_rpc_state()
14246 .with_response("eth_call", CALL_BOOL_TRUE)
14247 .with_call_response_sequence(
14248 ALLOWANCE_SELECTOR,
14249 &[
14250 CALL_ZERO,
14251 CALL_ZERO,
14252 CALL_ZERO,
14253 RPC_METHOD_NOT_FOUND,
14254 RPC_METHOD_NOT_FOUND,
14255 RPC_METHOD_NOT_FOUND,
14256 ],
14257 );
14258 let Some((admin_pool, schema, mut client, _)) =
14259 execution_client_with_database("execution_approve_postcondition_rpc_test", state).await
14260 else {
14261 return;
14262 };
14263
14264 let error = client
14265 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
14266 .await
14267 .unwrap_err();
14268
14269 let message = error.to_string();
14270 assert!(
14271 message.contains("failed to verify router allowance after included transaction 0x"),
14272 "was: {message}"
14273 );
14274 assert!(message.contains("at block 30346561"), "was: {message}");
14275 let in_flight = awaiting_in_flight(&client);
14276 assert_eq!(in_flight.purpose, TransactionPurpose::Approve);
14277 assert_eq!(
14278 execution_intent_markers(&admin_pool, &schema).await,
14279 vec![("approve".into(), "broadcast".into(), false, true)]
14280 );
14281
14282 drop_execution_schema(&admin_pool, &schema).await;
14283 }
14284
14285 #[tokio::test]
14286 async fn wallet_balance_refresh_replaces_complete_snapshot_with_exact_precision() {
14287 let state = execution_rpc_state()
14288 .with_response_sequence("eth_getBalance", &[GET_BALANCE, GET_BALANCE_ZERO])
14289 .with_response_sequence(
14290 "eth_call",
14291 &[
14292 CALL_BALANCE_WETH,
14293 CALL_BALANCE_USDC,
14294 CALL_BALANCE_WETH_UPDATED,
14295 CALL_BALANCE_USDC_UPDATED,
14296 ],
14297 );
14298 let (mut client, _, _) =
14299 client_with_token_mock_rpc(state, "BLOCKCHAIN_TEST_BALANCE_REPLACE").await;
14300 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14301 replace_exec_event_sender(sender);
14302 client.start().unwrap();
14303
14304 client.refresh_wallet_balances().await.unwrap();
14305 let balances = client.wallet_balance.lock().as_account_balances().unwrap();
14306
14307 assert_eq!(balances.len(), 3);
14308 assert_eq!(balances[0].currency.code.as_str(), "ETH");
14309 assert_eq!(balances[0].currency.name.as_str(), "Ethereum");
14310 assert_eq!(balances[0].currency.precision, 18);
14311 assert_eq!(balances[0].total.raw, 1_000_000_000_000_000_000);
14312 assert_eq!(balances[0].free, balances[0].total);
14313 assert_eq!(balances[0].locked, Money::zero(balances[0].currency));
14314 assert_eq!(balances[1].currency.code.as_str(), "WETH");
14315 assert_eq!(balances[1].currency.name.as_str(), "Wrapped Ether");
14316 assert_eq!(balances[1].currency.precision, 18);
14317 assert_eq!(balances[1].total.raw, 1_234_567_890_123_456_789);
14318 assert_eq!(balances[1].free, balances[1].total);
14319 assert_eq!(balances[1].locked, Money::zero(balances[1].currency));
14320 assert_eq!(balances[2].currency.code.as_str(), "USDC");
14321 assert_eq!(balances[2].currency.name.as_str(), "USD Coin");
14322 assert_eq!(balances[2].currency.precision, 6);
14323 assert_eq!(balances[2].total.raw, 9_876_543_210_000_000_000);
14324 assert_eq!(balances[2].free, balances[2].total);
14325 assert_eq!(balances[2].locked, Money::zero(balances[2].currency));
14326
14327 client.refresh_wallet_balances().await.unwrap();
14328 let balances = client.wallet_balance.lock().as_account_balances().unwrap();
14329
14330 assert_eq!(balances.len(), 3);
14331 assert_eq!(client.wallet_balance.lock().token_balances.len(), 2);
14332 assert_eq!(balances[0].total.raw, 0);
14333 assert_eq!(balances[1].total.raw, 2_000_000_000_000_000_000);
14334 assert_eq!(balances[2].total.raw, 12_345_670_000_000_000);
14335 }
14336
14337 #[allow(unsafe_code)] #[tokio::test]
14339 async fn failed_connect_refresh_retains_snapshot_and_publishes_nothing() {
14340 let state = execution_rpc_state()
14341 .with_response_sequence("eth_getBalance", &[GET_BALANCE, GET_BALANCE_ZERO])
14342 .with_response_sequence(
14343 "eth_call",
14344 &[
14345 CALL_BALANCE_WETH,
14346 CALL_BALANCE_USDC,
14347 CALL_BALANCE_WETH_UPDATED,
14348 RPC_METHOD_NOT_FOUND,
14349 ],
14350 );
14351 let (mut client, _, _) =
14352 client_with_token_mock_rpc(state, "BLOCKCHAIN_TEST_BALANCE_ATOMIC").await;
14353 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
14354 replace_exec_event_sender(sender);
14355 client.start().unwrap();
14356 client.refresh_wallet_balances().await.unwrap();
14357 let retained = client.wallet_balance.lock().as_account_balances().unwrap();
14358 receiver.try_recv().unwrap();
14359 unsafe { std::env::set_var("BLOCKCHAIN_TEST_BALANCE_ATOMIC", TEST_PRIVATE_KEY) };
14361
14362 let error = client.connect().await.unwrap_err();
14363 let failed_address = Address::from_str(USDC).unwrap();
14364
14365 assert!(
14366 error.to_string().contains(&format!(
14367 "failed to fetch token balance for {failed_address}"
14368 )),
14369 "was: {error}"
14370 );
14371 assert!(!client.is_connected());
14372 assert!(client.signer.is_none());
14373 assert_eq!(
14374 client.wallet_balance.lock().as_account_balances().unwrap(),
14375 retained
14376 );
14377 assert!(matches!(
14378 receiver.try_recv(),
14379 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
14380 ));
14381 }
14382
14383 #[allow(unsafe_code)] #[tokio::test]
14385 async fn failed_connect_publication_retains_snapshot() {
14386 let state = execution_rpc_state()
14387 .with_response_sequence("eth_getBalance", &[GET_BALANCE, GET_BALANCE_ZERO])
14388 .with_response_sequence(
14389 "eth_call",
14390 &[
14391 CALL_BALANCE_WETH,
14392 CALL_BALANCE_USDC,
14393 CALL_BALANCE_WETH_UPDATED,
14394 CALL_BALANCE_USDC_UPDATED,
14395 ],
14396 );
14397 let (mut client, _, _) =
14398 client_with_token_mock_rpc(state, "BLOCKCHAIN_TEST_BALANCE_PUBLICATION").await;
14399 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
14400 replace_exec_event_sender(sender);
14401 client.start().unwrap();
14402 client.refresh_wallet_balances().await.unwrap();
14403 let retained = client.wallet_balance.lock().as_account_balances().unwrap();
14404 receiver.try_recv().unwrap();
14405 drop(receiver);
14406 unsafe { std::env::set_var("BLOCKCHAIN_TEST_BALANCE_PUBLICATION", TEST_PRIVATE_KEY) };
14408
14409 let error = client.connect().await.unwrap_err();
14410
14411 assert!(
14412 error.to_string().contains("Failed to send account state"),
14413 "was: {error}"
14414 );
14415 assert!(!client.is_connected());
14416 assert!(client.signer.is_none());
14417 assert_eq!(
14418 client.wallet_balance.lock().as_account_balances().unwrap(),
14419 retained
14420 );
14421 }
14422
14423 #[allow(unsafe_code)] #[tokio::test]
14425 async fn connect_and_repeated_query_publish_wallet_account_state() {
14426 let state = execution_rpc_state()
14427 .with_response_sequence("eth_call", &[CALL_BALANCE_WETH, CALL_BALANCE_USDC]);
14428 let (mut client, state, cache) =
14429 client_with_token_mock_rpc(state, "BLOCKCHAIN_TEST_ACCOUNT_STATE").await;
14430 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
14431 replace_exec_event_sender(sender);
14432 client.start().unwrap();
14433 unsafe { std::env::set_var("BLOCKCHAIN_TEST_ACCOUNT_STATE", TEST_PRIVATE_KEY) };
14435
14436 client.connect().await.unwrap();
14437
14438 let ExecutionEvent::Account(connected) = receiver.try_recv().unwrap() else {
14439 panic!("expected account state event")
14440 };
14441 assert_eq!(connected.account_id, AccountId::from("BLOCKCHAIN-001"));
14442 assert_eq!(connected.account_type, AccountType::Wallet);
14443 assert_eq!(connected.base_currency, None);
14444 assert_eq!(connected.balances.len(), 3);
14445 assert!(connected.margins.is_empty());
14446 assert!(connected.is_reported);
14447 cache.borrow_mut().update_account_state(&connected).unwrap();
14448
14449 let account = client.get_account().unwrap();
14450 assert!(matches!(account, AccountAny::Wallet(_)));
14451 assert_eq!(account.id(), AccountId::from("BLOCKCHAIN-001"));
14452 assert_eq!(account.last_event(), Some(connected.clone()));
14453
14454 let requests_before = state.recorded_requests().len();
14455 let query = || {
14456 QueryAccount::new(
14457 TraderId::from("TRADER-001"),
14458 Some(ClientId::from("BLOCKCHAIN-001")),
14459 AccountId::from("BLOCKCHAIN-001"),
14460 UUID4::new(),
14461 UnixNanos::default(),
14462 None,
14463 None,
14464 )
14465 };
14466 client.query_account(query()).unwrap();
14467 client.query_account(query()).unwrap();
14468
14469 let ExecutionEvent::Account(first_query) = receiver.try_recv().unwrap() else {
14470 panic!("expected first query account state event")
14471 };
14472 let ExecutionEvent::Account(second_query) = receiver.try_recv().unwrap() else {
14473 panic!("expected second query account state event")
14474 };
14475 assert!(connected.has_same_balances_and_margins(&first_query));
14476 assert!(first_query.has_same_balances_and_margins(&second_query));
14477 assert_eq!(first_query.account_id, connected.account_id);
14478 assert_eq!(first_query.account_type, connected.account_type);
14479 assert_eq!(first_query.base_currency, connected.base_currency);
14480 assert_eq!(first_query.is_reported, connected.is_reported);
14481 assert_ne!(first_query.event_id, second_query.event_id);
14482 assert_eq!(state.recorded_requests().len(), requests_before);
14483
14484 client.stop().unwrap();
14485 assert!(client.core.is_stopped());
14486 assert!(!client.is_connected());
14487 assert!(client.signer.is_none());
14488 }
14489
14490 #[tokio::test]
14491 async fn connect_rejects_on_chain_mismatch() {
14492 let state = ready_rpc_state().with_response("eth_chainId", CHAIN_ID_ETHEREUM);
14493 let (mut client, _) = client_with_mock_rpc(state).await;
14494
14495 let error = client.connect().await.unwrap_err();
14496
14497 assert!(
14498 error
14499 .to_string()
14500 .contains("Blockchain chain ID verification disagreed"),
14501 "was: {error}"
14502 );
14503 }
14504
14505 #[allow(unsafe_code)] #[tokio::test]
14507 async fn connect_rejects_on_signer_wallet_mismatch() {
14508 let addr = start_mock_rpc_server(ready_rpc_state()).await;
14509 let config = test_config_with_signer_env(
14510 format!("http://{addr}"),
14511 "BLOCKCHAIN_TEST_PRIVATE_KEY_MISMATCH",
14512 );
14513 let mut client = test_client_from_config(config, test_pool());
14514 unsafe {
14517 std::env::set_var(
14518 "BLOCKCHAIN_TEST_PRIVATE_KEY_MISMATCH",
14519 "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
14520 )
14521 };
14522
14523 let error = client.connect().await.unwrap_err();
14524
14525 assert!(
14526 error
14527 .to_string()
14528 .contains("does not match configured wallet address"),
14529 "was: {error}"
14530 );
14531 }
14532
14533 #[allow(unsafe_code)] #[tokio::test]
14535 async fn connect_rejects_missing_trace_capability_before_signer_load() {
14536 let state = ready_rpc_state().with_parameter_response(
14537 "debug_traceTransaction",
14538 &B256::ZERO.to_string(),
14539 RPC_METHOD_NOT_FOUND,
14540 );
14541 let addr = start_mock_rpc_server(state.clone()).await;
14542 let config = test_config_with_signer_env(
14543 format!("http://{addr}"),
14544 "BLOCKCHAIN_TEST_TRACE_CAPABILITY",
14545 );
14546 let mut client = test_client_from_config(config, test_pool());
14547 unsafe { std::env::set_var("BLOCKCHAIN_TEST_TRACE_CAPABILITY", TEST_PRIVATE_KEY) };
14549
14550 let error = client.connect().await.unwrap_err();
14551
14552 assert!(
14553 error
14554 .to_string()
14555 .contains("Blockchain call trace capability verification is locally invalid"),
14556 "was: {error}"
14557 );
14558 assert!(client.signer.is_none());
14559 assert!(
14560 state
14561 .recorded_requests()
14562 .iter()
14563 .all(|request| request["method"] != "eth_getBalance")
14564 );
14565 }
14566
14567 #[allow(unsafe_code)] #[tokio::test]
14569 async fn connect_initializes_signer_from_env() {
14570 let addr = start_mock_rpc_server(ready_rpc_state()).await;
14571 let config =
14572 test_config_with_signer_env(format!("http://{addr}"), "BLOCKCHAIN_TEST_PRIVATE_KEY_OK");
14573 let mut client = test_client_from_config(config, test_pool());
14574 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14575 replace_exec_event_sender(sender);
14576 client.start().unwrap();
14577 unsafe { std::env::set_var("BLOCKCHAIN_TEST_PRIVATE_KEY_OK", TEST_PRIVATE_KEY) };
14579
14580 client.connect().await.unwrap();
14581
14582 let signer = client.signer.as_ref().unwrap();
14583 assert_eq!(
14584 signer.address(),
14585 address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266")
14586 );
14587 }
14588
14589 #[allow(unsafe_code)] #[tokio::test]
14591 async fn reconnect_resumes_at_the_durable_finalized_tip() {
14592 let Some((admin_pool, pg_config)) =
14593 connect_test_postgres("verification ledger resume").await
14594 else {
14595 return;
14596 };
14597 let schema = format!("verification_ledger_resume_{}", std::process::id());
14598 setup_execution_schema(&admin_pool, &schema).await;
14599 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
14600 let options = options.options([("search_path", schema.clone())]);
14601 let database = connect_test_database(options).await.unwrap();
14602 database
14603 .ensure_execution_transaction_schema()
14604 .await
14605 .unwrap();
14606
14607 let state = ready_rpc_state();
14608 let addr = start_mock_rpc_server(state.clone()).await;
14609 let config = test_config_with_signer_env(
14610 format!("http://{addr}"),
14611 "BLOCKCHAIN_TEST_VERIFICATION_RESUME",
14612 );
14613 let mut client = test_client_from_config(config, test_pool());
14614 client.cache.database = Some(database);
14615 protect_test_storage(&mut client, &schema).await;
14616 let finalized_headers = [
14617 ExecutionVerifiedHeader {
14618 number: FIXTURE_BLOCK,
14619 hash: FIXTURE_BLOCK_HASH.to_string(),
14620 parent_hash: "0x0000000000000000000000000000000000000000000000000000000000000001"
14621 .to_string(),
14622 timestamp: FIXTURE_BLOCK_TIMESTAMP,
14623 base_fee_per_gas: Some(100_000_000),
14624 },
14625 ExecutionVerifiedHeader {
14626 number: FIXTURE_BLOCK + 1,
14627 hash: B256::from([0x22; 32]).to_string(),
14628 parent_hash: FIXTURE_BLOCK_HASH.to_string(),
14629 timestamp: FIXTURE_BLOCK_TIMESTAMP + 1,
14630 base_fee_per_gas: Some(100_000_000),
14631 },
14632 ExecutionVerifiedHeader {
14633 number: FIXTURE_BLOCK + 2,
14634 hash: B256::from([0x33; 32]).to_string(),
14635 parent_hash: B256::from([0x22; 32]).to_string(),
14636 timestamp: FIXTURE_BLOCK_TIMESTAMP + 2,
14637 base_fee_per_gas: Some(100_000_000),
14638 },
14639 ];
14640 initialize_test_verification_ledger_with_headers(&client, &finalized_headers).await;
14641 let verification = client.config.verification.as_ref().unwrap();
14642 let position = client
14643 .cache
14644 .database
14645 .as_ref()
14646 .unwrap()
14647 .load_execution_verification_position(
14648 42_161,
14649 WALLET,
14650 &verification.manifest_version,
14651 &verification.manifest_digest,
14652 )
14653 .await
14654 .unwrap()
14655 .unwrap();
14656 let resume = client
14657 .cache
14658 .database
14659 .as_ref()
14660 .unwrap()
14661 .load_execution_verification_resume(
14662 42_161,
14663 WALLET,
14664 &verification.manifest_version,
14665 &verification.manifest_digest,
14666 )
14667 .await
14668 .unwrap()
14669 .unwrap();
14670 assert_eq!(position.next_canonical_nonce, 7);
14671 assert_eq!(position.revision, 0);
14672 assert_eq!(position.finalized_tip, finalized_headers[2]);
14673 assert_eq!(resume.next_canonical_nonce, 7);
14674 assert_eq!(resume.revision, 0);
14675 assert_eq!(resume.finalized_headers, finalized_headers);
14676
14677 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14678 replace_exec_event_sender(sender);
14679 client.start().unwrap();
14680 unsafe { std::env::set_var("BLOCKCHAIN_TEST_VERIFICATION_RESUME", TEST_PRIVATE_KEY) };
14682
14683 client.connect().await.unwrap();
14684
14685 let skipped_height = format!("0x{:x}", FIXTURE_BLOCK + 1);
14686 assert!(client.is_connected());
14687 assert!(state.recorded_requests().iter().all(|request| {
14688 request["method"] != "eth_getBlockByNumber"
14689 || request["params"][0].as_str() != Some(&skipped_height)
14690 }));
14691 let header_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
14692 "SELECT COUNT(*) FROM {schema}.execution_verified_finalized_header"
14693 )))
14694 .fetch_one(&admin_pool)
14695 .await
14696 .unwrap();
14697 assert_eq!(header_count, 3);
14698
14699 drop(client);
14700 drop_execution_schema(&admin_pool, &schema).await;
14701 }
14702
14703 #[tokio::test]
14704 async fn disconnect_revokes_signer_and_blocks_execution() {
14705 let (mut client, state) = client_with_mock_rpc(ready_rpc_state()).await;
14706 client.core.set_connected();
14707 client.signer = Some(Arc::new(
14708 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
14709 ));
14710
14711 client.disconnect().await.unwrap();
14712 let error = client.wrap(U256::from(1_000u64)).await.unwrap_err();
14713
14714 assert!(!client.is_connected());
14715 assert!(client.signer.is_none());
14716 assert!(
14717 error.to_string().contains("is not connected"),
14718 "was: {error}"
14719 );
14720 assert!(state.recorded_requests().is_empty());
14721 }
14722
14723 #[allow(unsafe_code)] #[tokio::test]
14725 async fn connect_releases_stale_preparing_claim_after_tasks_finish() {
14726 let addr = start_mock_rpc_server(ready_rpc_state()).await;
14727 let config = test_config_with_signer_env(
14728 format!("http://{addr}"),
14729 "BLOCKCHAIN_TEST_RECONNECT_CLAIM",
14730 );
14731 let mut client = test_client_from_config(config, test_pool());
14732 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14733 replace_exec_event_sender(sender);
14734 client.start().unwrap();
14735 unsafe { std::env::set_var("BLOCKCHAIN_TEST_RECONNECT_CLAIM", TEST_PRIVATE_KEY) };
14737 *client.in_flight.lock() = Some(InFlightSlot::Preparing(TransactionPurpose::Swap));
14738 client
14739 .pending_tasks
14740 .spawn(async {})
14741 .expect("stale task spawn");
14742
14743 client.connect().await.unwrap();
14744
14745 assert!(client.in_flight.lock().is_none());
14746 }
14747
14748 #[allow(unsafe_code)] #[tokio::test]
14750 async fn reconnect_after_aborted_submission_releases_preparing_claim() {
14751 let state = ready_rpc_state().with_sleep("eth_getBlockByNumber", Duration::from_secs(30));
14754 let Some((admin_pool, schema, mut client, state, _)) =
14755 swap_client_with_database("execution_reconnect_claim_test", state).await
14756 else {
14757 return;
14758 };
14759 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14760 replace_exec_event_sender(sender);
14761 client.start().unwrap();
14762 unsafe { std::env::set_var("BLOCKCHAIN_TEST_PRIVATE_KEY", TEST_PRIVATE_KEY) };
14764
14765 let order = test_market_sell_order(test_pool().instrument_id);
14766 client.submit_order(submit_order_cmd(&order)).unwrap();
14767 await_recorded_requests(&state, "eth_getBlockByNumber", 1).await;
14768 assert!(client.in_flight.lock().is_some());
14769
14770 client.disconnect().await.unwrap();
14771 let ready_addr = start_mock_rpc_server(ready_rpc_state()).await;
14772 let ready = test_client(format!("http://{ready_addr}"));
14773 client.http_rpc_client = ready.http_rpc_client;
14774 client.verification = ready.verification;
14775 client.connect().await.unwrap();
14776
14777 assert!(client.in_flight.lock().is_none());
14778
14779 drop_execution_schema(&admin_pool, &schema).await;
14780 }
14781
14782 #[tokio::test]
14783 async fn poll_for_receipt_returns_none_after_exhaustion() {
14784 let state = ready_rpc_state().with_response("eth_getTransactionReceipt", RECEIPT_NULL);
14785 let (client, state) = client_with_mock_rpc(state).await;
14786
14787 let receipt = poll_for_receipt(&client.http_rpc_client, &B256::ZERO, 3, Duration::ZERO)
14788 .await
14789 .unwrap();
14790
14791 assert!(receipt.is_none());
14792 let requests = state.recorded_requests();
14793 assert_eq!(requests.len(), 3);
14794 }
14795
14796 #[tokio::test]
14797 async fn poll_for_receipt_returns_last_error_when_every_poll_fails() {
14798 let state =
14799 ready_rpc_state().with_response("eth_getTransactionReceipt", RPC_METHOD_NOT_FOUND);
14800 let (client, state) = client_with_mock_rpc(state).await;
14801
14802 let error = poll_for_receipt(&client.http_rpc_client, &B256::ZERO, 3, Duration::ZERO)
14803 .await
14804 .unwrap_err();
14805
14806 assert_eq!(
14807 error.to_string(),
14808 "eth_getTransactionReceipt RPC error -32601"
14809 );
14810 let requests = state.recorded_requests();
14811 assert_eq!(requests.len(), 3);
14812 }
14813
14814 #[tokio::test]
14815 async fn poll_for_receipt_returns_none_after_pending_then_errors() {
14816 let state = ready_rpc_state().with_response_sequence(
14817 "eth_getTransactionReceipt",
14818 &[RECEIPT_NULL, RPC_METHOD_NOT_FOUND, RPC_METHOD_NOT_FOUND],
14819 );
14820 let (client, state) = client_with_mock_rpc(state).await;
14821
14822 let receipt = poll_for_receipt(&client.http_rpc_client, &B256::ZERO, 3, Duration::ZERO)
14823 .await
14824 .unwrap();
14825
14826 assert!(receipt.is_none());
14827 let requests = state.recorded_requests();
14828 assert_eq!(requests.len(), 3);
14829 }
14830
14831 #[tokio::test]
14832 async fn cancellation_during_persistence_keeps_in_flight_slot() {
14833 let Some((admin_pool, pg_config)) = connect_test_postgres("persistence cancellation").await
14834 else {
14835 return;
14836 };
14837
14838 let schema = format!("execution_persist_cancel_test_{}", std::process::id());
14839 setup_execution_schema(&admin_pool, &schema).await;
14840
14841 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
14842 let db_options = db_options.options([("search_path", schema.clone())]);
14843 let database = connect_test_database(db_options).await.unwrap();
14844
14845 let state = signing_rpc_state();
14846 let addr = start_mock_rpc_server(state.clone()).await;
14847
14848 let mut client = test_client(format!("http://{addr}"));
14849 client.cache.database = Some(database);
14850 client
14852 .cache
14853 .ensure_execution_transaction_schema()
14854 .await
14855 .unwrap();
14856 protect_test_storage(&mut client, &schema).await;
14857 initialize_test_verification_ledger(&client).await;
14858 client.signer = Some(Arc::new(
14859 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
14860 ));
14861 client.core.set_connected();
14862
14863 let advisory_lock = i64::from(std::process::id());
14864
14865 for statement in [
14866 format!(
14867 "CREATE FUNCTION {schema}.block_execution_hash_insert() RETURNS trigger \
14868 LANGUAGE plpgsql AS 'BEGIN PERFORM pg_advisory_xact_lock({advisory_lock}); \
14869 RETURN NEW; END'"
14870 ),
14871 format!(
14872 "CREATE TRIGGER block_execution_hash_insert BEFORE INSERT ON \
14873 {schema}.execution_transaction_hash FOR EACH ROW EXECUTE FUNCTION \
14874 {schema}.block_execution_hash_insert()"
14875 ),
14876 ] {
14877 sqlx::query(sqlx::AssertSqlSafe(statement))
14878 .execute(&admin_pool)
14879 .await
14880 .unwrap();
14881 }
14882 let mut lock_transaction = admin_pool.begin().await.unwrap();
14883 sqlx::query("SELECT pg_advisory_xact_lock($1)")
14884 .bind(advisory_lock)
14885 .execute(&mut *lock_transaction)
14886 .await
14887 .unwrap();
14888
14889 let value = U256::from(1_000_000_000_000_000u64);
14890 let in_flight = Arc::clone(&client.in_flight);
14891 let mut wrap = Box::pin(client.wrap(value));
14892 tokio::time::timeout(Duration::from_secs(2), async {
14893 loop {
14894 tokio::select! {
14895 result = &mut wrap => {
14896 panic!("persistence completed while the table was locked: {result:?}")
14897 }
14898 () = tokio::time::sleep(Duration::from_millis(1)) => {}
14899 }
14900
14901 if matches!(*in_flight.lock(), Some(InFlightSlot::AwaitingFinality(_))) {
14902 break;
14903 }
14904 }
14905 })
14906 .await
14907 .unwrap();
14908 drop(wrap);
14909 lock_transaction.rollback().await.unwrap();
14910
14911 let slot = *client.in_flight.lock();
14912 let second_error = client
14913 .wrap(U256::from(2_000_000_000_000_000u64))
14914 .await
14915 .unwrap_err();
14916 let broadcasts = state
14917 .recorded_requests()
14918 .into_iter()
14919 .filter(|request| request["method"] == "eth_sendRawTransaction")
14920 .count();
14921 let status: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
14922 "SELECT status FROM {schema}.execution_intent"
14923 )))
14924 .fetch_one(&admin_pool)
14925 .await
14926 .unwrap();
14927
14928 assert!(matches!(slot, Some(InFlightSlot::AwaitingFinality(_))));
14929 assert_eq!(status, "prepared");
14930 assert!(
14931 second_error.to_string().contains("awaiting finality"),
14932 "was: {second_error}"
14933 );
14934 assert_eq!(broadcasts, 0);
14935
14936 drop_execution_schema(&admin_pool, &schema).await;
14937 }
14938
14939 #[tokio::test]
14940 async fn reservation_failure_releases_preparing_slot() {
14941 let state = signing_rpc_state();
14942 let Some((admin_pool, schema, mut client, state)) =
14943 execution_client_with_database("execution_reservation_fail_test", state).await
14944 else {
14945 return;
14946 };
14947 sqlx::query(sqlx::AssertSqlSafe(format!(
14948 "DROP TABLE {schema}.execution_intent CASCADE"
14949 )))
14950 .execute(&admin_pool)
14951 .await
14952 .unwrap();
14953
14954 let error = client
14955 .wrap(U256::from(1_000_000_000_000_000u64))
14956 .await
14957 .unwrap_err();
14958 let retry_error = client
14959 .wrap(U256::from(2_000_000_000_000_000u64))
14960 .await
14961 .unwrap_err();
14962 let broadcasts = state
14963 .recorded_requests()
14964 .into_iter()
14965 .filter(|request| request["method"] == "eth_sendRawTransaction")
14966 .count();
14967
14968 assert_eq!(
14969 error.to_string(),
14970 "Execution intent reservation failed before commit"
14971 );
14972 assert!(reservation_failure_proven_not_committed(&error));
14973 assert_eq!(
14974 retry_error.to_string(),
14975 "Execution intent reservation failed before commit"
14976 );
14977 assert_eq!(broadcasts, 0);
14978 assert!(client.in_flight.lock().is_none());
14979
14980 drop_execution_schema(&admin_pool, &schema).await;
14981 }
14982
14983 #[tokio::test]
14984 async fn reservation_commit_failure_keeps_preparing_slot() {
14985 let state = signing_rpc_state();
14986 let Some((admin_pool, schema, mut client, state)) =
14987 execution_client_with_database("execution_reservation_commit_fail_test", state).await
14988 else {
14989 return;
14990 };
14991 install_reservation_commit_rejection(&admin_pool, &schema).await;
14992
14993 let error = client
14994 .wrap(U256::from(1_000_000_000_000_000u64))
14995 .await
14996 .unwrap_err();
14997 let slot = *client.in_flight.lock();
14998 let second_error = client
14999 .wrap(U256::from(2_000_000_000_000_000u64))
15000 .await
15001 .unwrap_err();
15002 let requests = state.recorded_requests();
15003 let intent_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
15004 "SELECT COUNT(*) FROM {schema}.execution_intent"
15005 )))
15006 .fetch_one(&admin_pool)
15007 .await
15008 .unwrap();
15009 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
15010 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
15011 )))
15012 .fetch_one(&admin_pool)
15013 .await
15014 .unwrap();
15015
15016 assert_eq!(
15017 error.to_string(),
15018 "Execution intent reservation commit outcome is unknown; reconciliation is required"
15019 );
15020 assert!(!reservation_failure_proven_not_committed(&error));
15021 assert!(matches!(
15022 slot,
15023 Some(InFlightSlot::Preparing(TransactionPurpose::Wrap))
15024 ));
15025 assert!(
15026 second_error.to_string().contains("being prepared"),
15027 "was: {second_error}"
15028 );
15029 assert_eq!(intent_count, 0);
15030 assert_eq!(signed_count, 0);
15031 assert!(
15032 requests
15033 .iter()
15034 .all(|request| request["method"] != "eth_getTransactionCount")
15035 );
15036 assert!(
15037 requests
15038 .iter()
15039 .all(|request| request["method"] != "eth_sendRawTransaction")
15040 );
15041
15042 drop_execution_schema(&admin_pool, &schema).await;
15043 }
15044
15045 #[tokio::test]
15046 async fn persistence_failure_keeps_unbroadcast_slot() {
15047 let state = signing_rpc_state();
15048 let Some((admin_pool, schema, mut client, state)) =
15049 execution_client_with_database("execution_persist_fail_test", state).await
15050 else {
15051 return;
15052 };
15053 sqlx::query(sqlx::AssertSqlSafe(format!(
15054 "ALTER TABLE {schema}.execution_transaction_hash \
15055 ADD CONSTRAINT execution_hash_reject CHECK (FALSE)"
15056 )))
15057 .execute(&admin_pool)
15058 .await
15059 .unwrap();
15060
15061 let value = U256::from(1_000_000_000_000_000u64);
15062 let error = client.wrap(value).await.unwrap_err();
15063 let in_flight = awaiting_in_flight(&client);
15064 let second_error = client
15065 .wrap(U256::from(2_000_000_000_000_000u64))
15066 .await
15067 .unwrap_err();
15068 let broadcasts = state
15069 .recorded_requests()
15070 .into_iter()
15071 .filter(|request| request["method"] == "eth_sendRawTransaction")
15072 .count();
15073 let expected_hash = expected_wrap_tx_hash(value).await;
15074
15075 let error_message = error.to_string();
15076 assert!(error_message.starts_with(&format!(
15077 "Failed to persist transaction {expected_hash}: Failed to persist signed transaction"
15078 )), "was: {error_message}");
15079 assert!(
15080 error_message.ends_with("the in-flight slot stays occupied"),
15081 "was: {error_message}"
15082 );
15083 assert_eq!(in_flight.nonce, 7);
15084 assert_eq!(in_flight.tx_hash, expected_hash);
15085 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15086 assert!(
15087 second_error.to_string().contains("still awaiting finality"),
15088 "was: {second_error}"
15089 );
15090 assert_eq!(broadcasts, 0);
15091
15092 drop_execution_schema(&admin_pool, &schema).await;
15093 }
15094
15095 #[tokio::test]
15096 async fn cancellation_after_dispatch_keeps_record_and_in_flight_slot() {
15097 let state = signing_rpc_state().with_sleep(
15098 "eth_sendRawTransaction",
15099 Duration::from_secs(EXECUTION_RPC_TIMEOUT_SECS + 2),
15100 );
15101 let Some((admin_pool, schema, mut client, state)) =
15102 execution_client_with_database("execution_cancel_test", state).await
15103 else {
15104 return;
15105 };
15106
15107 let mut wrap = Box::pin(client.wrap(U256::from(1_000_000_000_000_000u64)));
15108 tokio::select! {
15109 result = &mut wrap => panic!("broadcast completed before cancellation: {result:?}"),
15110 () = await_recorded_requests(&state, "eth_sendRawTransaction", 1) => {}
15111 }
15112 drop(wrap);
15113
15114 let in_flight = awaiting_in_flight(&client);
15115 let error = client
15116 .wrap(U256::from(2_000_000_000_000_000u64))
15117 .await
15118 .unwrap_err();
15119 let record = client
15120 .cache
15121 .get_execution_transaction(42161, &in_flight.tx_hash.to_string())
15122 .await
15123 .unwrap()
15124 .unwrap();
15125 let broadcasts = state
15126 .recorded_requests()
15127 .into_iter()
15128 .filter(|request| request["method"] == "eth_sendRawTransaction")
15129 .count();
15130
15131 assert!(
15132 error.to_string().contains("still awaiting finality"),
15133 "was: {error}"
15134 );
15135 assert_eq!(record.nonce, 7);
15136 assert_eq!(record.purpose, "wrap");
15137 assert_eq!(record.status, "broadcast");
15138 assert_eq!(broadcasts, 1);
15139
15140 drop_execution_schema(&admin_pool, &schema).await;
15141 }
15142
15143 #[tokio::test]
15144 async fn cancellation_during_receipt_polling_keeps_record_and_in_flight_slot() {
15145 let state = signing_rpc_state()
15146 .with_send_raw_transaction_echo()
15147 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS)
15148 .with_sleep(
15149 "eth_getTransactionReceipt",
15150 Duration::from_secs(EXECUTION_RPC_TIMEOUT_SECS + 2),
15151 );
15152 let Some((admin_pool, schema, mut client, state)) =
15153 execution_client_with_database("execution_receipt_cancel_test", state).await
15154 else {
15155 return;
15156 };
15157
15158 let mut wrap = Box::pin(client.wrap(U256::from(1_000_000_000_000_000u64)));
15159 tokio::select! {
15160 result = &mut wrap => panic!("receipt polling completed before cancellation: {result:?}"),
15161 () = await_recorded_requests(&state, "eth_getTransactionReceipt", 3) => {}
15162 }
15163 drop(wrap);
15164
15165 let in_flight = awaiting_in_flight(&client);
15166 let second_error = client
15167 .wrap(U256::from(2_000_000_000_000_000u64))
15168 .await
15169 .unwrap_err();
15170 let record = client
15171 .cache
15172 .get_execution_transaction(42161, &in_flight.tx_hash.to_string())
15173 .await
15174 .unwrap()
15175 .unwrap();
15176 let requests = state.recorded_requests();
15177
15178 assert_eq!(in_flight.nonce, 7);
15179 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15180 assert!(
15181 second_error.to_string().contains("still awaiting finality"),
15182 "was: {second_error}"
15183 );
15184 assert_eq!(record.nonce, 7);
15185 assert_eq!(record.transaction_hash, in_flight.tx_hash.to_string());
15186 assert_eq!(record.purpose, "wrap");
15187 assert_eq!(record.status, "broadcast");
15188 assert_eq!(
15189 requests
15190 .iter()
15191 .filter(|request| request["method"] == "eth_sendRawTransaction")
15192 .count(),
15193 1
15194 );
15195 assert_eq!(
15196 requests
15197 .iter()
15198 .filter(|request| request["method"] == "eth_getTransactionReceipt")
15199 .count(),
15200 3
15201 );
15202
15203 drop_execution_schema(&admin_pool, &schema).await;
15204 }
15205
15206 #[tokio::test]
15207 async fn rejected_broadcast_stays_dropped_and_occupied() {
15208 let state = signing_rpc_state()
15209 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_REJECTED)
15210 .with_response("eth_getTransactionReceipt", RECEIPT_NULL);
15211 let Some((admin_pool, schema, mut client, state)) =
15212 execution_client_with_database("execution_rejected_test", state).await
15213 else {
15214 return;
15215 };
15216
15217 let error = client
15218 .wrap(U256::from(1_000_000_000_000_000u64))
15219 .await
15220 .unwrap_err();
15221 let (purpose, status): (String, String) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
15222 "SELECT purpose, status FROM {schema}.execution_intent"
15223 )))
15224 .fetch_one(&admin_pool)
15225 .await
15226 .unwrap();
15227 let broadcasts = state
15228 .recorded_requests()
15229 .into_iter()
15230 .filter(|request| request["method"] == "eth_sendRawTransaction")
15231 .count();
15232
15233 assert!(
15234 error.to_string().contains("Timed out awaiting finality"),
15235 "was: {error}"
15236 );
15237 assert!(client.in_flight.lock().is_some());
15238 assert_eq!(purpose, "wrap");
15239 assert_eq!(status, "dropped");
15240 assert_eq!(broadcasts, 1);
15241
15242 drop_execution_schema(&admin_pool, &schema).await;
15243 }
15244
15245 #[tokio::test]
15246 async fn legacy_table_loss_does_not_release_rejected_broadcast() {
15247 let state = signing_rpc_state()
15248 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_REJECTED)
15249 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
15250 .with_sleep("eth_sendRawTransaction", Duration::from_secs(1));
15251 let Some((admin_pool, schema, mut client, state)) =
15252 execution_client_with_database("execution_rejected_update_test", state).await
15253 else {
15254 return;
15255 };
15256
15257 let mut wrap = Box::pin(client.wrap(U256::from(1_000_000_000_000_000u64)));
15258 tokio::select! {
15259 result = &mut wrap => panic!("broadcast completed before database failure: {result:?}"),
15260 () = await_recorded_requests(&state, "eth_sendRawTransaction", 1) => {}
15261 }
15262 sqlx::query(sqlx::AssertSqlSafe(format!(
15263 "DROP TABLE {schema}.execution_transaction"
15264 )))
15265 .execute(&admin_pool)
15266 .await
15267 .unwrap();
15268
15269 let error = wrap.await.unwrap_err();
15270
15271 assert!(
15272 error.to_string().contains("Timed out awaiting finality"),
15273 "was: {error}"
15274 );
15275 let in_flight = awaiting_in_flight(&client);
15276 assert_eq!(in_flight.nonce, 7);
15277 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15278
15279 drop_execution_schema(&admin_pool, &schema).await;
15280 }
15281
15282 #[tokio::test]
15283 async fn finalized_receipt_keeps_slot_when_status_update_fails() {
15284 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000u64)).await;
15285 let block = finalized_wrap_block(expected_hash);
15286 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
15287 let state = with_finalized_identity(
15288 signing_rpc_state()
15289 .with_send_raw_transaction_echo()
15290 .with_response("eth_getTransactionReceipt", &receipt)
15291 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
15292 .with_call_response_sequence(
15293 BALANCE_OF_SELECTOR,
15294 &[
15295 CALL_BALANCE,
15296 CALL_BALANCE,
15297 CALL_BALANCE,
15298 CALL_BALANCE,
15299 CALL_BALANCE,
15300 CALL_BALANCE,
15301 CALL_BALANCE_AFTER_WRAP,
15302 CALL_BALANCE_AFTER_WRAP,
15303 CALL_BALANCE_AFTER_WRAP,
15304 ],
15305 ),
15306 &block,
15307 &receipt,
15308 );
15309 let Some((admin_pool, schema, mut client, state)) =
15310 execution_client_with_database("execution_finalized_update_test", state).await
15311 else {
15312 return;
15313 };
15314
15315 for statement in [
15316 format!(
15317 "CREATE FUNCTION {schema}.reject_finalized_status() RETURNS trigger \
15318 LANGUAGE plpgsql AS 'BEGIN IF NEW.status = ''finalized'' THEN \
15319 RAISE EXCEPTION ''test finalized status rejection''; END IF; \
15320 RETURN NEW; END'"
15321 ),
15322 format!(
15323 "CREATE TRIGGER reject_finalized_status BEFORE UPDATE ON \
15324 {schema}.execution_transaction_hash FOR EACH ROW \
15325 EXECUTE FUNCTION {schema}.reject_finalized_status()"
15326 ),
15327 ] {
15328 sqlx::query(sqlx::AssertSqlSafe(statement))
15329 .execute(&admin_pool)
15330 .await
15331 .unwrap();
15332 }
15333
15334 let error = client
15335 .wrap(U256::from(1_000_000_000_000_000u64))
15336 .await
15337 .unwrap_err();
15338 let in_flight = awaiting_in_flight(&client);
15339 let requests = state.recorded_requests();
15340
15341 assert!(
15342 error
15343 .to_string()
15344 .contains("test finalized status rejection"),
15345 "was: {error}"
15346 );
15347 assert_eq!(in_flight.nonce, 7);
15348 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15349 assert_eq!(
15350 requests
15351 .iter()
15352 .filter(|request| request["method"] == "eth_sendRawTransaction")
15353 .count(),
15354 1
15355 );
15356 assert_eq!(
15357 requests
15358 .iter()
15359 .filter(|request| request["method"] == "eth_getTransactionReceipt")
15360 .count(),
15361 3
15362 );
15363
15364 drop_execution_schema(&admin_pool, &schema).await;
15365 }
15366
15367 #[tokio::test]
15368 async fn broadcast_timeout_marks_dropped_and_keeps_ownership() {
15369 let state = signing_rpc_state()
15370 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
15371 .with_sleep(
15372 "eth_sendRawTransaction",
15373 Duration::from_secs(EXECUTION_RPC_TIMEOUT_SECS + 2),
15374 );
15375 let Some((admin_pool, schema, mut client, _)) =
15376 execution_client_with_database("execution_timeout_test", state).await
15377 else {
15378 return;
15379 };
15380
15381 let error = client
15382 .wrap(U256::from(1_000_000_000_000_000u64))
15383 .await
15384 .unwrap_err();
15385
15386 assert!(
15387 error.to_string().contains("Timed out awaiting finality"),
15388 "was: {error}"
15389 );
15390 let in_flight = awaiting_in_flight(&client);
15391 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15392 assert_eq!(in_flight.nonce, 7);
15393
15394 let record = client
15396 .cache
15397 .get_execution_transaction(42161, &in_flight.tx_hash.to_string())
15398 .await
15399 .unwrap()
15400 .unwrap();
15401 assert_eq!(record.status, "dropped");
15402
15403 drop_execution_schema(&admin_pool, &schema).await;
15404 }
15405
15406 #[tokio::test]
15407 async fn restart_reconciles_dropped_transaction_without_rebroadcast() {
15408 let initial_state = broadcast_rpc_state()
15409 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
15410 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
15411 let Some((admin_pool, schema, mut first_client, _)) =
15412 execution_client_with_database("execution_restart_test", initial_state).await
15413 else {
15414 return;
15415 };
15416 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
15417 let error = first_client
15418 .wrap(U256::from(1_000_000_000_000_000_u64))
15419 .await
15420 .unwrap_err();
15421 assert!(
15422 error.to_string().contains("Timed out awaiting finality"),
15423 "was: {error}"
15424 );
15425 let database = first_client.cache.database.as_ref().unwrap().clone();
15426 let payload_keys = first_client.payload_keys.clone();
15427 drop(first_client);
15428
15429 let block = finalized_wrap_block(expected_hash);
15430 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
15431 let restart_state = with_finalized_identity(
15432 execution_rpc_state()
15433 .with_response("eth_getTransactionReceipt", &receipt)
15434 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
15435 .with_response_sequence(
15436 "eth_call",
15437 &[
15438 CALL_BALANCE,
15439 CALL_BALANCE,
15440 CALL_BALANCE,
15441 CALL_BALANCE_AFTER_WRAP,
15442 CALL_BALANCE_AFTER_WRAP,
15443 CALL_BALANCE_AFTER_WRAP,
15444 ],
15445 ),
15446 &block,
15447 &receipt,
15448 );
15449 let addr = start_mock_rpc_server(restart_state.clone()).await;
15450 let mut restarted = test_client(format!("http://{addr}"));
15451 restarted.cache.database = Some(database);
15452 restarted.payload_keys = payload_keys;
15453 restarted.signer = Some(Arc::new(
15454 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
15455 ));
15456
15457 restarted.reconcile_unresolved_execution().await.unwrap();
15458 restarted.reconcile_unresolved_execution().await.unwrap();
15459
15460 let record = restarted
15461 .cache
15462 .get_execution_transaction(42161, &expected_hash.to_string())
15463 .await
15464 .unwrap()
15465 .unwrap();
15466 let requests = restart_state.recorded_requests();
15467 assert_eq!(record.status, "finalized");
15468 assert!(restarted.in_flight.lock().is_none());
15469 assert_eq!(
15470 requests
15471 .iter()
15472 .filter(|request| request["method"] == "eth_getTransactionReceipt")
15473 .count(),
15474 6
15475 );
15476 assert_eq!(
15477 requests
15478 .iter()
15479 .filter(|request| {
15480 request["method"] == "eth_call"
15481 && request["params"][0]["data"]
15482 .as_str()
15483 .is_some_and(|data| data.starts_with(BALANCE_OF_SELECTOR))
15484 })
15485 .count(),
15486 6
15487 );
15488 assert_eq!(
15489 requests
15490 .iter()
15491 .filter(|request| request["method"] == "eth_sendRawTransaction")
15492 .count(),
15493 0
15494 );
15495
15496 drop_execution_schema(&admin_pool, &schema).await;
15497 }
15498
15499 fn migration_test_intent(id: i64) -> ExecutionIntentRow {
15500 ExecutionIntentRow {
15501 id,
15502 schema_version: crate::execution::transaction::EXECUTION_SCHEMA_VERSION,
15503 chain_id: 42_161,
15504 wallet_address: WALLET.to_string(),
15505 nonce: None,
15506 purpose: "wrap".to_string(),
15507 status: "prepared".to_string(),
15508 client_order_id: None,
15509 trader_id: None,
15510 strategy_id: None,
15511 account_id: None,
15512 instrument_id: None,
15513 pool_address: None,
15514 transaction_to: WETH.to_string(),
15515 transaction_input: "0xd0e30db0".to_string(),
15516 transaction_value: "1".to_string(),
15517 amount_in: None,
15518 created_block: FIXTURE_BLOCK,
15519 acknowledgement_emitted: false,
15520 fill_emitted: false,
15521 terminal_emitted: false,
15522 active: true,
15523 }
15524 }
15525
15526 fn migration_nonce_verification() -> Verified<u64> {
15527 Verified {
15528 value: 7,
15529 read: crate::rpc::verification::VerificationRead::TransactionCount,
15530 provider_ids: [
15531 "authoritative".to_string(),
15532 "verifier-a".to_string(),
15533 "verifier-b".to_string(),
15534 ],
15535 normalized_value_digest: keccak256(7_u64.to_be_bytes()),
15536 }
15537 }
15538
15539 fn migration_finalized_header() -> VerifiedBlockHeader {
15540 VerifiedBlockHeader {
15541 number: FIXTURE_BLOCK,
15542 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
15543 parent_hash: B256::from_str(
15544 "0x0000000000000000000000000000000000000000000000000000000000000001",
15545 )
15546 .unwrap(),
15547 timestamp: FIXTURE_BLOCK_TIMESTAMP,
15548 base_fee_per_gas: Some(100_000_000),
15549 }
15550 }
15551
15552 #[tokio::test]
15553 async fn verification_migration_recovers_prepared_unassigned_intent() {
15554 let client = test_client("http://127.0.0.1:1".to_string());
15555 let snapshot = ExecutionVerificationMigrationSnapshot {
15556 intents: vec![migration_test_intent(1)],
15557 hashes: Vec::new(),
15558 };
15559 let finalized = migration_finalized_header();
15560 let migration = client
15561 .build_execution_verification_migration(
15562 snapshot,
15563 finalized,
15564 &[finalized],
15565 &migration_nonce_verification(),
15566 )
15567 .await
15568 .unwrap();
15569
15570 assert_eq!(migration.records.len(), 1);
15571 let record = &migration.records[0];
15572 assert_eq!(record.intent_id, 1);
15573 assert_eq!(record.nonce, None);
15574 assert_eq!(record.transaction_hash, None);
15575 assert!(record.recover_prepared);
15576 assert_eq!(record.decisions.len(), 1);
15577 }
15578
15579 #[tokio::test]
15580 async fn verification_migration_rejects_inconsistent_released_history() {
15581 let client = test_client("http://127.0.0.1:1".to_string());
15582 let finalized = migration_finalized_header();
15583 let nonce_verification = migration_nonce_verification();
15584 let mut missing_marker = migration_test_intent(1);
15585 missing_marker.active = false;
15586 missing_marker.status = "finalized".to_string();
15587 missing_marker.nonce = Some(6);
15588 let error = client
15589 .build_execution_verification_migration(
15590 ExecutionVerificationMigrationSnapshot {
15591 intents: vec![missing_marker],
15592 hashes: Vec::new(),
15593 },
15594 finalized,
15595 &[finalized],
15596 &nonce_verification,
15597 )
15598 .await
15599 .err()
15600 .unwrap();
15601 assert!(
15602 error.to_string().contains("has no durable event marker"),
15603 "was: {error}"
15604 );
15605
15606 let mut first = migration_test_intent(1);
15607 first.active = false;
15608 first.status = "recoverable".to_string();
15609 first.nonce = Some(6);
15610 let mut second = first.clone();
15611 second.id = 2;
15612 let error = client
15613 .build_execution_verification_migration(
15614 ExecutionVerificationMigrationSnapshot {
15615 intents: vec![first, second],
15616 hashes: Vec::new(),
15617 },
15618 finalized,
15619 &[finalized],
15620 &nonce_verification,
15621 )
15622 .await
15623 .err()
15624 .unwrap();
15625 assert!(
15626 error
15627 .to_string()
15628 .contains("duplicate signer nonce ownership"),
15629 "was: {error}"
15630 );
15631 }
15632
15633 #[tokio::test]
15634 async fn verification_migration_reconstructs_consumed_active_intent() {
15635 let expected_hash = expected_wrap_tx_hash(U256::from(1u64)).await;
15636 let mut block_value: serde_json::Value =
15637 serde_json::from_str(&finalized_wrap_block(expected_hash)).unwrap();
15638 block_value["result"]["transactions"][0]["value"] = serde_json::json!("0x1");
15639 let block = block_value.to_string();
15640 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
15641 let state = with_finalized_identity(
15642 execution_rpc_state()
15643 .with_response("eth_getTransactionCount", TRANSACTION_COUNT_NEXT)
15644 .with_response("eth_getTransactionReceipt", &receipt)
15645 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block),
15646 &block,
15647 &receipt,
15648 );
15649 let Some((admin_pool, pg_config)) =
15650 connect_test_postgres("verification_migration_reconstructs_consumed_active_intent")
15651 .await
15652 else {
15653 return;
15654 };
15655 let schema = format!(
15656 "verification_migration_reconstructs_consumed_active_intent_{}",
15657 std::process::id()
15658 );
15659 setup_execution_schema(&admin_pool, &schema).await;
15660 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
15661 let database = connect_test_database(options.options([("search_path", schema.clone())]))
15662 .await
15663 .unwrap();
15664 let addr = start_mock_rpc_server(state).await;
15665 let (mut client, _) = swap_client_with_cache(test_config(format!("http://{addr}")));
15666 client.cache.database = Some(database.clone());
15667 client
15668 .cache
15669 .ensure_execution_transaction_schema()
15670 .await
15671 .unwrap();
15672 let (intent, persisted_hash, _) = persist_test_wrap_broadcast(&database, None).await;
15673 protect_test_storage(&mut client, &schema).await;
15674 assert_eq!(persisted_hash, expected_hash);
15675
15676 let snapshot = database
15677 .load_execution_verification_migration_snapshot(42_161, WALLET)
15678 .await
15679 .unwrap();
15680 let nonce_verification = required_verification(
15681 client
15682 .verification
15683 .verify_transaction_count(&Address::from_str(WALLET).unwrap(), FIXTURE_BLOCK + 1)
15684 .await,
15685 "test migration nonce",
15686 )
15687 .unwrap();
15688 let headers = [
15689 VerifiedBlockHeader {
15690 number: FIXTURE_BLOCK,
15691 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
15692 parent_hash: B256::from_str(
15693 "0x0000000000000000000000000000000000000000000000000000000000000001",
15694 )
15695 .unwrap(),
15696 timestamp: FIXTURE_BLOCK_TIMESTAMP,
15697 base_fee_per_gas: Some(100_000_000),
15698 },
15699 VerifiedBlockHeader {
15700 number: FIXTURE_BLOCK + 1,
15701 hash: B256::from([0x22; 32]),
15702 parent_hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
15703 timestamp: FIXTURE_BLOCK_TIMESTAMP + 1,
15704 base_fee_per_gas: Some(100_000_000),
15705 },
15706 ];
15707 let migration = client
15708 .build_execution_verification_migration(
15709 snapshot,
15710 headers[1],
15711 &headers,
15712 &nonce_verification,
15713 )
15714 .await
15715 .unwrap();
15716 let finalized_headers = headers
15717 .iter()
15718 .map(|header| ExecutionVerifiedHeader {
15719 number: header.number,
15720 hash: header.hash.to_string(),
15721 parent_hash: header.parent_hash.to_string(),
15722 timestamp: header.timestamp,
15723 base_fee_per_gas: header.base_fee_per_gas,
15724 })
15725 .collect::<Vec<_>>();
15726 let decisions = [verification_decision(
15727 &nonce_verification,
15728 Some(FIXTURE_BLOCK + 1),
15729 Some(FIXTURE_BLOCK + 1),
15730 )];
15731
15732 initialize_test_verification_migration(
15733 &client,
15734 &finalized_headers,
15735 8,
15736 &decisions,
15737 &migration,
15738 )
15739 .await;
15740
15741 let migrated = database.get_execution_intent(intent.id).await.unwrap();
15742 let resume = database
15743 .load_execution_verification_resume(
15744 42_161,
15745 WALLET,
15746 &client
15747 .config
15748 .verification
15749 .as_ref()
15750 .unwrap()
15751 .manifest_version,
15752 &client.config.verification.as_ref().unwrap().manifest_digest,
15753 )
15754 .await
15755 .unwrap()
15756 .unwrap();
15757 let evidence: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
15758 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
15759 WHERE intent_id = $1 AND decision_class = 'migration'"
15760 )))
15761 .bind(intent.id)
15762 .fetch_one(&admin_pool)
15763 .await
15764 .unwrap();
15765
15766 assert_eq!(migrated.status, "finalized");
15767 assert!(migrated.active);
15768 assert_eq!(resume.next_canonical_nonce, 8);
15769 assert!(evidence >= 5);
15770
15771 drop_execution_schema(&admin_pool, &schema).await;
15772 }
15773
15774 #[tokio::test]
15775 async fn verification_resume_accepts_one_consumed_owned_nonce() {
15776 let Some((admin_pool, schema, client, _)) = execution_client_with_database(
15777 "verification_resume_owned_nonce_test",
15778 ready_rpc_state(),
15779 )
15780 .await
15781 else {
15782 return;
15783 };
15784 let database = client.cache.database.as_ref().unwrap();
15785 persist_test_wrap_broadcast(database, client.payload_keys.as_deref()).await;
15786 let resume = database
15787 .load_execution_verification_resume(
15788 42_161,
15789 WALLET,
15790 &client
15791 .config
15792 .verification
15793 .as_ref()
15794 .unwrap()
15795 .manifest_version,
15796 &client.config.verification.as_ref().unwrap().manifest_digest,
15797 )
15798 .await
15799 .unwrap()
15800 .unwrap();
15801
15802 ensure_test_verification_ledger(&client, &resume.finalized_headers, 7, 8)
15803 .await
15804 .unwrap();
15805
15806 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
15807 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
15808 )))
15809 .fetch_one(&admin_pool)
15810 .await
15811 .unwrap();
15812 assert_eq!(nonce_state, (7, 0));
15813
15814 drop_execution_schema(&admin_pool, &schema).await;
15815 }
15816
15817 #[tokio::test]
15818 async fn verification_resume_rejects_unowned_nonce_advance() {
15819 for (test_name, mutation, observed_canonical_nonce) in [
15820 ("verification_resume_no_active_test", "no_active", 8),
15821 ("verification_resume_excess_test", "unchanged", 9),
15822 ("verification_resume_signed_test", "signed", 8),
15823 ("verification_resume_prepared_test", "prepared", 8),
15824 (
15825 "verification_resume_missing_payload_test",
15826 "missing_payload",
15827 8,
15828 ),
15829 ] {
15830 let Some((admin_pool, schema, client, _)) =
15831 execution_client_with_database(test_name, ready_rpc_state()).await
15832 else {
15833 return;
15834 };
15835 let database = client.cache.database.as_ref().unwrap();
15836 let (intent, _, _) =
15837 persist_test_wrap_broadcast(database, client.payload_keys.as_deref()).await;
15838
15839 match mutation {
15840 "no_active" => {
15841 sqlx::query(sqlx::AssertSqlSafe(format!(
15842 "UPDATE {schema}.execution_intent SET active = FALSE WHERE id = $1"
15843 )))
15844 .bind(intent.id)
15845 .execute(&admin_pool)
15846 .await
15847 .unwrap();
15848 }
15849 "signed" => {
15850 sqlx::query(sqlx::AssertSqlSafe(format!(
15851 "UPDATE {schema}.execution_intent SET status = 'signed' WHERE id = $1"
15852 )))
15853 .bind(intent.id)
15854 .execute(&admin_pool)
15855 .await
15856 .unwrap();
15857 sqlx::query(sqlx::AssertSqlSafe(format!(
15858 "UPDATE {schema}.execution_transaction_hash SET status = 'signed' \
15859 WHERE intent_id = $1"
15860 )))
15861 .bind(intent.id)
15862 .execute(&admin_pool)
15863 .await
15864 .unwrap();
15865 }
15866 "prepared" => {
15867 sqlx::query(sqlx::AssertSqlSafe(format!(
15868 "UPDATE {schema}.execution_intent \
15869 SET nonce = NULL, status = 'prepared' 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 current = FALSE \
15877 WHERE intent_id = $1"
15878 )))
15879 .bind(intent.id)
15880 .execute(&admin_pool)
15881 .await
15882 .unwrap();
15883 }
15884 "missing_payload" => {
15885 sqlx::query(sqlx::AssertSqlSafe(format!(
15886 "UPDATE {schema}.execution_transaction_hash \
15887 SET payload_expected = FALSE, sealed_transaction = NULL \
15888 WHERE intent_id = $1"
15889 )))
15890 .bind(intent.id)
15891 .execute(&admin_pool)
15892 .await
15893 .unwrap();
15894 }
15895 "unchanged" => {}
15896 _ => unreachable!(),
15897 }
15898 let resume = database
15899 .load_execution_verification_resume(
15900 42_161,
15901 WALLET,
15902 &client
15903 .config
15904 .verification
15905 .as_ref()
15906 .unwrap()
15907 .manifest_version,
15908 &client.config.verification.as_ref().unwrap().manifest_digest,
15909 )
15910 .await
15911 .unwrap()
15912 .unwrap();
15913
15914 let error = ensure_test_verification_ledger(
15915 &client,
15916 &resume.finalized_headers,
15917 7,
15918 observed_canonical_nonce,
15919 )
15920 .await
15921 .unwrap_err();
15922
15923 assert!(
15924 error
15925 .to_string()
15926 .contains(if observed_canonical_nonce == 9 {
15927 "outside the owned recovery range"
15928 } else {
15929 "without"
15930 }),
15931 "{mutation}: {error}"
15932 );
15933 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
15934 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
15935 )))
15936 .fetch_one(&admin_pool)
15937 .await
15938 .unwrap();
15939 assert_eq!(nonce_state, (7, 0), "{mutation}");
15940
15941 drop_execution_schema(&admin_pool, &schema).await;
15942 }
15943 }
15944
15945 #[tokio::test]
15946 async fn restart_marks_prepared_intent_recoverable_without_broadcast() {
15947 let Some((admin_pool, schema, client, state)) =
15948 execution_client_with_database("execution_prepared_restart_test", ready_rpc_state())
15949 .await
15950 else {
15951 return;
15952 };
15953 let database = client.cache.database.as_ref().unwrap();
15954 let intent = reserve_test_wrap_intent(database).await;
15955 *client.in_flight.lock() = Some(InFlightSlot::Preparing(TransactionPurpose::Wrap));
15956
15957 client.reconcile_unresolved_execution().await.unwrap();
15958
15959 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
15960 "SELECT status, active FROM {schema}.execution_intent WHERE id = {}",
15961 intent.id
15962 )))
15963 .fetch_one(&admin_pool)
15964 .await
15965 .unwrap();
15966 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
15967 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
15968 )))
15969 .fetch_all(&admin_pool)
15970 .await
15971 .unwrap();
15972
15973 assert_eq!(status, "recoverable");
15974 assert!(!active);
15975 assert_eq!(transitions, ["prepared", "recoverable"]);
15976 assert!(client.in_flight.lock().is_none());
15977 assert!(
15978 state
15979 .recorded_requests()
15980 .iter()
15981 .all(|request| request["method"] != "eth_sendRawTransaction")
15982 );
15983
15984 drop_execution_schema(&admin_pool, &schema).await;
15985 }
15986
15987 #[tokio::test]
15988 async fn protected_restart_authenticates_envelope_before_recovery() {
15989 let initial_state = broadcast_rpc_state()
15990 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
15991 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
15992 let Some((admin_pool, schema, mut first_client, _)) =
15993 execution_client_with_database("protected_restart_test", initial_state).await
15994 else {
15995 return;
15996 };
15997 let database = first_client.cache.database.as_ref().unwrap().clone();
15998 let value = U256::from(1_000_000_000_000_000_u64);
15999 let expected_hash = expected_wrap_tx_hash(value).await;
16000
16001 let error = first_client.wrap(value).await.unwrap_err();
16002
16003 assert!(error.to_string().contains("Timed out awaiting finality"));
16004 let intent = database
16005 .get_active_execution_intent(42161, WALLET)
16006 .await
16007 .unwrap()
16008 .unwrap();
16009 let payload = database
16010 .get_execution_transaction_hashes(intent.id)
16011 .await
16012 .unwrap()
16013 .pop()
16014 .unwrap();
16015 assert!(payload.raw_transaction.is_none());
16016 assert!(payload.sealed_transaction.is_some());
16017 let keys = first_client.payload_keys.take().unwrap();
16018 drop(first_client);
16019
16020 let block = finalized_wrap_block(expected_hash);
16021 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
16022 let restart_state = with_finalized_identity(
16023 execution_rpc_state()
16024 .with_response("eth_getTransactionReceipt", &receipt)
16025 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
16026 .with_response_sequence(
16027 "eth_call",
16028 &[
16029 CALL_BALANCE,
16030 CALL_BALANCE,
16031 CALL_BALANCE,
16032 CALL_BALANCE_AFTER_WRAP,
16033 CALL_BALANCE_AFTER_WRAP,
16034 CALL_BALANCE_AFTER_WRAP,
16035 ],
16036 ),
16037 &block,
16038 &receipt,
16039 );
16040 let addr = start_mock_rpc_server(restart_state.clone()).await;
16041 let mut restarted = test_client(format!("http://{addr}"));
16042 restarted.cache.database = Some(database);
16043 restarted.payload_keys = Some(keys);
16044 restarted.signer = Some(Arc::new(
16045 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16046 ));
16047
16048 restarted.reconcile_unresolved_execution().await.unwrap();
16049
16050 let record = restarted
16051 .cache
16052 .get_execution_transaction(42161, &expected_hash.to_string())
16053 .await
16054 .unwrap()
16055 .unwrap();
16056 let requests = restart_state.recorded_requests();
16057 assert_eq!(record.status, "finalized");
16058 assert!(restarted.in_flight.lock().is_none());
16059 assert_eq!(
16060 requests
16061 .iter()
16062 .filter(|request| request["method"] == "eth_sendRawTransaction")
16063 .count(),
16064 0
16065 );
16066
16067 drop_execution_schema(&admin_pool, &schema).await;
16068 }
16069
16070 #[tokio::test]
16071 async fn active_intent_reconciliation_waits_for_reservation_fence() {
16072 let Some((admin_pool, schema, client, _)) = execution_client_with_database(
16073 "execution_active_intent_reservation_fence_test",
16074 ready_rpc_state(),
16075 )
16076 .await
16077 else {
16078 return;
16079 };
16080 let database = client.cache.database.as_ref().unwrap().clone();
16081 let lock = PgAdvisoryLock::new(format!(
16082 "nautilus:blockchain:execution:42161:{}",
16083 WALLET.to_ascii_lowercase()
16084 ));
16085 let PgAdvisoryLockKey::BigInt(lock_key) = lock.key() else {
16086 unreachable!("string advisory locks use the 64-bit key space");
16087 };
16088 let mut reservation = admin_pool.begin().await.unwrap();
16089 sqlx::query("SELECT pg_advisory_xact_lock($1)")
16090 .bind(*lock_key)
16091 .execute(&mut *reservation)
16092 .await
16093 .unwrap();
16094 sqlx::query(sqlx::AssertSqlSafe(format!(
16095 "INSERT INTO {schema}.execution_intent (\
16096 schema_version, chain_id, wallet_address, purpose, status, transaction_to, \
16097 transaction_input, transaction_value, created_block\
16098 ) VALUES (2, $1, $2, 'wrap', 'prepared', $3, '0xd0e30db0', '1', $4)"
16099 )))
16100 .bind(42161_i32)
16101 .bind(WALLET)
16102 .bind(WETH_ADDRESS.to_string())
16103 .bind(i64::try_from(FIXTURE_BLOCK).unwrap())
16104 .execute(&mut *reservation)
16105 .await
16106 .unwrap();
16107
16108 let mut reconciliation = Box::pin(database.get_active_execution_intent(42161, WALLET));
16109 assert!(
16110 tokio::time::timeout(Duration::from_millis(100), &mut reconciliation)
16111 .await
16112 .is_err(),
16113 "reconciliation did not wait for the reservation fence"
16114 );
16115
16116 reservation.commit().await.unwrap();
16117 let intent = tokio::time::timeout(Duration::from_secs(2), reconciliation)
16118 .await
16119 .unwrap()
16120 .unwrap()
16121 .unwrap();
16122
16123 assert_eq!(intent.chain_id, 42161);
16124 assert_eq!(intent.wallet_address, WALLET);
16125 assert_eq!(intent.purpose, "wrap");
16126 assert_eq!(intent.status, "prepared");
16127 assert!(intent.active);
16128
16129 drop_execution_schema(&admin_pool, &schema).await;
16130 }
16131
16132 #[tokio::test]
16133 async fn restart_keeps_unbroadcast_signed_intent_reserved() {
16134 let Some((admin_pool, schema, client, state)) =
16135 execution_client_with_database("execution_signed_restart_test", ready_rpc_state())
16136 .await
16137 else {
16138 return;
16139 };
16140 let database = client.cache.database.as_ref().unwrap();
16141 let intent = reserve_test_wrap_intent(database).await;
16142 let transaction = build_eip1559_transaction(
16143 42161,
16144 7,
16145 78_000,
16146 130_000_000,
16147 10_000_000,
16148 WETH_ADDRESS,
16149 U256::from(1u64),
16150 Bytes::from(nautilus_core::hex::decode("d0e30db0").unwrap()),
16151 );
16152 let (tx_hash, raw_transaction) = sign_eip1559_transaction(
16153 transaction,
16154 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16155 )
16156 .await
16157 .unwrap();
16158 database
16159 .assign_execution_intent_nonce(intent.id, 7)
16160 .await
16161 .unwrap();
16162 let intent = database.get_execution_intent(intent.id).await.unwrap();
16163 persist_test_payload(
16164 database,
16165 client.payload_keys.as_deref(),
16166 &intent,
16167 tx_hash,
16168 &raw_transaction,
16169 )
16170 .await;
16171
16172 let error = client.reconcile_unresolved_execution().await.unwrap_err();
16173
16174 assert!(
16175 error
16176 .to_string()
16177 .contains("was not authorized for broadcast"),
16178 "was: {error}"
16179 );
16180 let recovery = recovering_in_flight(&client);
16181 assert_eq!(recovery.intent_id, intent.id);
16182 assert_eq!(recovery.nonce, 7);
16183 assert_eq!(recovery.purpose, TransactionPurpose::Wrap);
16184 assert_eq!(
16185 execution_intent_markers(&admin_pool, &schema).await,
16186 vec![("wrap".into(), "signed".into(), false, true)]
16187 );
16188 assert!(
16189 state
16190 .recorded_requests()
16191 .iter()
16192 .all(|request| request["method"] != "eth_sendRawTransaction")
16193 );
16194
16195 drop_execution_schema(&admin_pool, &schema).await;
16196 }
16197
16198 #[tokio::test]
16199 async fn restart_quarantines_legacy_recoverable_signed_intent() {
16200 let Some((admin_pool, schema, client, state)) = execution_client_with_database(
16201 "execution_legacy_recoverable_restart_test",
16202 ready_rpc_state(),
16203 )
16204 .await
16205 else {
16206 return;
16207 };
16208 let database = client.cache.database.as_ref().unwrap();
16209 let intent = reserve_test_wrap_intent(database).await;
16210 let tx_hash = B256::from([0x55; 32]);
16211 database
16212 .assign_execution_intent_nonce(intent.id, 7)
16213 .await
16214 .unwrap();
16215 let intent = database.get_execution_intent(intent.id).await.unwrap();
16216 persist_test_payload(
16217 database,
16218 client.payload_keys.as_deref(),
16219 &intent,
16220 tx_hash,
16221 &[0x01, 0x02, 0x03],
16222 )
16223 .await;
16224 sqlx::query(sqlx::AssertSqlSafe(format!(
16225 "UPDATE {schema}.execution_intent SET status = 'recoverable', active = FALSE WHERE id = {}",
16226 intent.id
16227 )))
16228 .execute(&admin_pool)
16229 .await
16230 .unwrap();
16231
16232 let error = client.reconcile_unresolved_execution().await.unwrap_err();
16233
16234 assert!(
16235 error
16236 .to_string()
16237 .contains("retains signed transaction bytes"),
16238 "was: {error}"
16239 );
16240 assert!(client.in_flight.lock().is_none());
16241 assert!(
16242 state
16243 .recorded_requests()
16244 .iter()
16245 .all(|request| request["method"] != "eth_sendRawTransaction")
16246 );
16247
16248 drop_execution_schema(&admin_pool, &schema).await;
16249 }
16250
16251 #[rstest]
16252 #[case::current("execution_invalid_signed_restart_test", false, "broadcast")]
16253 #[case::historical("execution_invalid_historical_restart_test", true, "replaced")]
16254 #[tokio::test]
16255 async fn restart_rejects_invalid_signed_bytes_before_recovery_effects(
16256 #[case] test_name: &str,
16257 #[case] historical: bool,
16258 #[case] expected_status: &str,
16259 ) {
16260 let Some((admin_pool, schema, first_client, state, _)) =
16261 swap_client_with_database(test_name, ready_rpc_state()).await
16262 else {
16263 return;
16264 };
16265 let database = first_client.cache.database.as_ref().unwrap().clone();
16266 let (intent, _) =
16267 persist_invalid_test_swap(&database, first_client.payload_keys.as_deref()).await;
16268
16269 if historical {
16270 sqlx::query(sqlx::AssertSqlSafe(format!(
16271 "UPDATE {schema}.execution_transaction_hash \
16272 SET current = FALSE, status = 'replaced' WHERE intent_id = $1"
16273 )))
16274 .bind(intent.id)
16275 .execute(&admin_pool)
16276 .await
16277 .unwrap();
16278 sqlx::query(sqlx::AssertSqlSafe(format!(
16279 "INSERT INTO {schema}.execution_transaction_hash (\
16280 intent_id, chain_id, transaction_hash, payload_expected, status, current\
16281 ) VALUES ($1, 42161, $2, FALSE, 'replaced', TRUE)"
16282 )))
16283 .bind(intent.id)
16284 .bind(B256::from([0x44; 32]).to_string())
16285 .execute(&admin_pool)
16286 .await
16287 .unwrap();
16288 sqlx::query(sqlx::AssertSqlSafe(format!(
16289 "UPDATE {schema}.execution_intent SET status = 'replaced' WHERE id = $1"
16290 )))
16291 .bind(intent.id)
16292 .execute(&admin_pool)
16293 .await
16294 .unwrap();
16295 }
16296 let transitions_before: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16297 "SELECT COUNT(*) FROM {schema}.execution_transaction_transition"
16298 )))
16299 .fetch_one(&admin_pool)
16300 .await
16301 .unwrap();
16302 let config = first_client.config.clone();
16303 let payload_keys = first_client.payload_keys.clone();
16304 drop(first_client);
16305
16306 let (mut restarted, _) = swap_client_with_cache(config);
16307 restarted.cache.database = Some(database);
16308 restarted.payload_keys = payload_keys;
16309 restarted.signer = Some(Arc::new(
16310 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16311 ));
16312 let mut receiver = start_with_events(&mut restarted);
16313
16314 let error = restarted
16315 .reconcile_unresolved_execution()
16316 .await
16317 .unwrap_err();
16318
16319 let recovery = recovering_in_flight(&restarted);
16320 let (status, acknowledgement_emitted, active): (String, bool, bool) =
16321 sqlx::query_as(sqlx::AssertSqlSafe(format!(
16322 "SELECT status, acknowledgement_emitted, active FROM {schema}.execution_intent"
16323 )))
16324 .fetch_one(&admin_pool)
16325 .await
16326 .unwrap();
16327 let transitions_after: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16328 "SELECT COUNT(*) FROM {schema}.execution_transaction_transition"
16329 )))
16330 .fetch_one(&admin_pool)
16331 .await
16332 .unwrap();
16333
16334 assert!(
16335 error
16336 .to_string()
16337 .contains("is not a complete EIP-2718 envelope"),
16338 "was: {error}"
16339 );
16340 assert_eq!(recovery.intent_id, intent.id);
16341 assert_eq!(recovery.nonce, 7);
16342 assert_eq!(recovery.purpose, TransactionPurpose::Swap);
16343 assert_eq!(status, expected_status);
16344 assert!(!acknowledgement_emitted);
16345 assert!(active);
16346 assert_eq!(transitions_after, transitions_before);
16347 assert!(collect_order_events(&mut receiver).is_empty());
16348 assert!(
16349 state
16350 .recorded_requests()
16351 .iter()
16352 .all(|request| request["method"] != "eth_sendRawTransaction")
16353 );
16354
16355 drop_execution_schema(&admin_pool, &schema).await;
16356 }
16357
16358 #[tokio::test]
16359 async fn restart_rebroadcasts_only_durably_authorized_bytes() {
16360 let Some((admin_pool, schema, first_client, _)) =
16361 execution_client_with_database("execution_broadcast_restart_test", ready_rpc_state())
16362 .await
16363 else {
16364 return;
16365 };
16366 let database = first_client.cache.database.as_ref().unwrap().clone();
16367 let (_, _, raw_tx) =
16368 persist_test_wrap_broadcast(&database, first_client.payload_keys.as_deref()).await;
16369 let payload_keys = first_client.payload_keys.clone();
16370 drop(first_client);
16371
16372 let restart_state = execution_rpc_state()
16373 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
16374 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16375 .with_response("eth_call", CALL_EMPTY)
16376 .with_send_raw_transaction_echo();
16377 let addr = start_mock_rpc_server(restart_state.clone()).await;
16378 let mut restarted = test_client(format!("http://{addr}"));
16379 restarted.cache.database = Some(database);
16380 restarted.payload_keys = payload_keys;
16381 restarted.signer = Some(Arc::new(
16382 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16383 ));
16384
16385 restarted.reconcile_unresolved_execution().await.unwrap();
16386
16387 let broadcasts = restart_state
16388 .recorded_requests()
16389 .into_iter()
16390 .filter(|request| request["method"] == "eth_sendRawTransaction")
16391 .collect::<Vec<_>>();
16392 assert_eq!(broadcasts.len(), 1);
16393 assert_eq!(broadcasts[0]["params"][0], hex::encode_prefixed(&raw_tx));
16394 assert_eq!(
16395 execution_intent_markers(&admin_pool, &schema).await,
16396 vec![("wrap".into(), "dropped".into(), false, true)]
16397 );
16398
16399 drop_execution_schema(&admin_pool, &schema).await;
16400 }
16401
16402 #[tokio::test]
16403 async fn restart_suppresses_rebroadcast_when_canonical_nonce_advanced() {
16404 let Some((admin_pool, schema, first_client, _)) = execution_client_with_database(
16405 "execution_rebroadcast_nonce_advanced_test",
16406 ready_rpc_state(),
16407 )
16408 .await
16409 else {
16410 return;
16411 };
16412 let database = first_client.cache.database.as_ref().unwrap().clone();
16413 let (intent, _, _) =
16414 persist_test_wrap_broadcast(&database, first_client.payload_keys.as_deref()).await;
16415 let payload_keys = first_client.payload_keys.clone();
16416 drop(first_client);
16417
16418 let mut empty_block: serde_json::Value =
16419 serde_json::from_str(&replacement_head_block(B256::from([0x44; 32]))).unwrap();
16420 empty_block["result"]["transactions"] = serde_json::json!([]);
16421 let empty_block = empty_block.to_string();
16422 let restart_state = execution_rpc_state()
16423 .with_response("eth_getTransactionCount", TRANSACTION_COUNT_NEXT)
16424 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16425 .with_response("eth_call", CALL_EMPTY)
16426 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d40", &empty_block)
16427 .with_send_raw_transaction_echo();
16428 let addr = start_mock_rpc_server(restart_state.clone()).await;
16429 let mut restarted = test_client(format!("http://{addr}"));
16430 restarted.cache.database = Some(database);
16431 restarted.payload_keys = payload_keys;
16432 restarted.signer = Some(Arc::new(
16433 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16434 ));
16435
16436 let error = restarted
16437 .reconcile_unresolved_execution()
16438 .await
16439 .unwrap_err();
16440
16441 let requests = restart_state.recorded_requests();
16442 assert!(
16443 requests
16444 .iter()
16445 .all(|request| request["method"] != "eth_sendRawTransaction")
16446 );
16447 assert!(requests.iter().all(|request| {
16448 request["method"] != "eth_call" || request["params"][0]["data"] != "0xd0e30db0"
16449 }));
16450 let decision_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16451 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16452 WHERE intent_id = $1 AND decision_class = 'rebroadcast'"
16453 )))
16454 .bind(intent.id)
16455 .fetch_one(&admin_pool)
16456 .await
16457 .unwrap();
16458 assert!(
16459 error
16460 .to_string()
16461 .contains("without an authenticated signer transaction"),
16462 "was: {error}"
16463 );
16464 assert_eq!(decision_count, 6);
16465 let replacement_decision_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16466 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16467 WHERE intent_id = $1 AND decision_class = 'replacement_scan'"
16468 )))
16469 .bind(intent.id)
16470 .fetch_one(&admin_pool)
16471 .await
16472 .unwrap();
16473 assert_eq!(replacement_decision_count, 2);
16474 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
16475 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
16476 )))
16477 .fetch_one(&admin_pool)
16478 .await
16479 .unwrap();
16480 assert_eq!(nonce_state, (7, 0));
16481
16482 drop_execution_schema(&admin_pool, &schema).await;
16483 }
16484
16485 #[tokio::test]
16486 async fn restart_suppresses_rebroadcast_when_receipt_exists() {
16487 let Some((admin_pool, schema, first_client, _)) = execution_client_with_database(
16488 "execution_rebroadcast_receipt_present_test",
16489 ready_rpc_state(),
16490 )
16491 .await
16492 else {
16493 return;
16494 };
16495 let database = first_client.cache.database.as_ref().unwrap().clone();
16496 let (intent, _, _) =
16497 persist_test_wrap_broadcast(&database, first_client.payload_keys.as_deref()).await;
16498 let payload_keys = first_client.payload_keys.clone();
16499 drop(first_client);
16500
16501 let restart_state = execution_rpc_state()
16502 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
16503 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS)
16504 .with_response("eth_call", CALL_EMPTY)
16505 .with_send_raw_transaction_echo();
16506 let addr = start_mock_rpc_server(restart_state.clone()).await;
16507 let mut restarted = test_client(format!("http://{addr}"));
16508 restarted.cache.database = Some(database);
16509 restarted.payload_keys = payload_keys;
16510 restarted.signer = Some(Arc::new(
16511 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16512 ));
16513
16514 let error = restarted
16515 .reconcile_unresolved_execution()
16516 .await
16517 .unwrap_err();
16518
16519 assert!(
16520 error
16521 .to_string()
16522 .contains("finalized transaction verification is locally invalid"),
16523 "was: {error}"
16524 );
16525 let requests = restart_state.recorded_requests();
16526 assert!(
16527 requests
16528 .iter()
16529 .all(|request| request["method"] != "eth_sendRawTransaction")
16530 );
16531 assert!(requests.iter().all(|request| {
16532 request["method"] != "eth_call" || request["params"][0]["data"] != "0xd0e30db0"
16533 }));
16534 let decision_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16535 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16536 WHERE intent_id = $1 AND decision_class = 'rebroadcast'"
16537 )))
16538 .bind(intent.id)
16539 .fetch_one(&admin_pool)
16540 .await
16541 .unwrap();
16542 assert_eq!(decision_count, 6);
16543
16544 drop_execution_schema(&admin_pool, &schema).await;
16545 }
16546
16547 #[rstest]
16548 #[case("execution_rebroadcast_false_test", CALL_ZERO)]
16549 #[case("execution_rebroadcast_revert_test", CALL_REVERTED)]
16550 #[tokio::test]
16551 async fn restart_suppresses_rebroadcast_when_simulation_denies(
16552 #[case] test_name: &str,
16553 #[case] simulation_response: &str,
16554 ) {
16555 let Some((admin_pool, schema, first_client, _)) =
16556 execution_client_with_database(test_name, ready_rpc_state()).await
16557 else {
16558 return;
16559 };
16560 let database = first_client.cache.database.as_ref().unwrap().clone();
16561 let (intent, _, _) =
16562 persist_test_wrap_broadcast(&database, first_client.payload_keys.as_deref()).await;
16563 let payload_keys = first_client.payload_keys.clone();
16564 drop(first_client);
16565
16566 let restart_state = execution_rpc_state()
16567 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
16568 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16569 .with_response("eth_call", simulation_response)
16570 .with_send_raw_transaction_echo();
16571 let addr = start_mock_rpc_server(restart_state.clone()).await;
16572 let mut restarted = test_client(format!("http://{addr}"));
16573 restarted.cache.database = Some(database);
16574 restarted.payload_keys = payload_keys;
16575 restarted.signer = Some(Arc::new(
16576 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16577 ));
16578
16579 restarted.reconcile_unresolved_execution().await.unwrap();
16580
16581 let requests = restart_state.recorded_requests();
16582 assert!(
16583 requests
16584 .iter()
16585 .all(|request| request["method"] != "eth_sendRawTransaction")
16586 );
16587 let simulation_calls = requests
16588 .iter()
16589 .filter(|request| {
16590 request["method"] == "eth_call"
16591 && request["params"][0]["data"] == "0xd0e30db0"
16592 && request["params"][1] == FIXTURE_BLOCK_PARAM
16593 })
16594 .count();
16595 assert_eq!(simulation_calls, 3);
16596 let decision_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16597 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16598 WHERE intent_id = $1 AND decision_class = 'rebroadcast'"
16599 )))
16600 .bind(intent.id)
16601 .fetch_one(&admin_pool)
16602 .await
16603 .unwrap();
16604 assert_eq!(decision_count, 7);
16605
16606 drop_execution_schema(&admin_pool, &schema).await;
16607 }
16608
16609 #[tokio::test]
16610 async fn restart_wrap_identity_mismatch_keeps_signer_ownership() {
16611 let initial_state = broadcast_rpc_state()
16612 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16613 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16614 let Some((admin_pool, schema, mut first_client, _)) =
16615 execution_client_with_database("execution_restart_mismatch_test", initial_state).await
16616 else {
16617 return;
16618 };
16619 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
16620 let error = first_client
16621 .wrap(U256::from(1_000_000_000_000_000_u64))
16622 .await
16623 .unwrap_err();
16624 assert!(
16625 error.to_string().contains("Timed out awaiting finality"),
16626 "was: {error}"
16627 );
16628 let database = first_client.cache.database.as_ref().unwrap().clone();
16629 let payload_keys = first_client.payload_keys.clone();
16630 drop(first_client);
16631
16632 let mismatched_block = serde_json::json!({
16634 "jsonrpc": "2.0",
16635 "id": 1,
16636 "result": {
16637 "number": "0x1cf0d41",
16638 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
16639 "parentHash": FIXTURE_BLOCK_HASH,
16640 "timestamp": "0x69044a21",
16641 "baseFeePerGas": "0x5f5e100",
16642 "transactions": [{
16643 "hash": expected_hash.to_string(),
16644 "from": WALLET,
16645 "nonce": "0x7",
16646 "chainId": "0xa4b1",
16647 "type": "0x2",
16648 "to": WETH,
16649 "input": "0xd0e30db0",
16650 "value": "0x0",
16651 "gas": "0x130b0",
16652 "maxFeePerGas": "0x7bfa480",
16653 "maxPriorityFeePerGas": "0x989680"
16654 }]
16655 }
16656 })
16657 .to_string();
16658 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
16659 let restart_state = with_finalized_identity(
16660 execution_rpc_state()
16661 .with_response("eth_getTransactionReceipt", &receipt)
16662 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &mismatched_block),
16663 &mismatched_block,
16664 &receipt,
16665 );
16666 let addr = start_mock_rpc_server(restart_state.clone()).await;
16667 let mut restarted = test_client(format!("http://{addr}"));
16668 restarted.cache.database = Some(database);
16669 restarted.payload_keys = payload_keys;
16670 restarted.signer = Some(Arc::new(
16671 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16672 ));
16673
16674 let error = restarted
16675 .reconcile_unresolved_execution()
16676 .await
16677 .unwrap_err();
16678
16679 assert!(
16680 error
16681 .to_string()
16682 .contains("finalized transaction identity mismatch"),
16683 "was: {error}"
16684 );
16685 let in_flight = awaiting_in_flight(&restarted);
16686 assert_eq!(in_flight.nonce, 7);
16687 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
16688 assert_eq!(in_flight.tx_hash, expected_hash);
16689 let requests = restart_state.recorded_requests();
16690 assert!(
16691 requests.iter().all(|request| {
16692 request["method"] != "eth_call"
16693 || !request["params"][0]["data"]
16694 .as_str()
16695 .is_some_and(|data| data.starts_with(BALANCE_OF_SELECTOR))
16696 }),
16697 "the postcondition must not run when call identity is unproven"
16698 );
16699 assert!(
16700 requests
16701 .iter()
16702 .all(|request| request["method"] != "eth_sendRawTransaction")
16703 );
16704 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
16705 "SELECT status, active FROM {schema}.execution_intent"
16706 )))
16707 .fetch_one(&admin_pool)
16708 .await
16709 .unwrap();
16710 assert_eq!(status, "dropped");
16711 assert!(active);
16712
16713 let database = restarted.cache.database.as_ref().unwrap().clone();
16714 let payload_keys = restarted.payload_keys.clone();
16715 drop(restarted);
16716 let mut second = test_client(format!("http://{addr}"));
16717 second.cache.database = Some(database);
16718 second.payload_keys = payload_keys;
16719 second.signer = Some(Arc::new(
16720 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16721 ));
16722 let error = second.reconcile_unresolved_execution().await.unwrap_err();
16723 assert!(
16724 error
16725 .to_string()
16726 .contains("finalized transaction identity mismatch"),
16727 "was: {error}"
16728 );
16729 let active: bool = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16730 "SELECT active FROM {schema}.execution_intent"
16731 )))
16732 .fetch_one(&admin_pool)
16733 .await
16734 .unwrap();
16735 assert!(active);
16736
16737 drop_execution_schema(&admin_pool, &schema).await;
16738 }
16739
16740 #[tokio::test]
16741 async fn restart_wrap_postcondition_failure_keeps_signer_ownership() {
16742 let initial_state = broadcast_rpc_state()
16743 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16744 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16745 let Some((admin_pool, schema, mut first_client, _)) =
16746 execution_client_with_database("execution_restart_postcondition_test", initial_state)
16747 .await
16748 else {
16749 return;
16750 };
16751 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
16752 let error = first_client
16753 .wrap(U256::from(1_000_000_000_000_000_u64))
16754 .await
16755 .unwrap_err();
16756 assert!(
16757 error.to_string().contains("Timed out awaiting finality"),
16758 "was: {error}"
16759 );
16760 let database = first_client.cache.database.as_ref().unwrap().clone();
16761 let payload_keys = first_client.payload_keys.clone();
16762 drop(first_client);
16763
16764 let failing_wrap_state = || {
16766 let block = finalized_wrap_block(expected_hash);
16767 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
16768 with_finalized_identity(
16769 execution_rpc_state()
16770 .with_response("eth_getTransactionReceipt", &receipt)
16771 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
16772 .with_response_sequence("eth_call", &[CALL_BALANCE; 6]),
16773 &block,
16774 &receipt,
16775 )
16776 };
16777 let restart_state = failing_wrap_state();
16778 let addr = start_mock_rpc_server(restart_state.clone()).await;
16779 let mut restarted = test_client(format!("http://{addr}"));
16780 restarted.cache.database = Some(database);
16781 restarted.payload_keys = payload_keys;
16782 restarted.signer = Some(Arc::new(
16783 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16784 ));
16785
16786 let error = restarted
16787 .reconcile_unresolved_execution()
16788 .await
16789 .unwrap_err();
16790
16791 assert!(
16792 error.to_string().contains("did not increase by"),
16793 "was: {error}"
16794 );
16795 let in_flight = awaiting_in_flight(&restarted);
16796 assert_eq!(in_flight.nonce, 7);
16797 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
16798 assert!(
16799 restart_state
16800 .recorded_requests()
16801 .iter()
16802 .all(|request| request["method"] != "eth_sendRawTransaction")
16803 );
16804 assert_eq!(
16805 execution_intent_markers(&admin_pool, &schema).await,
16806 vec![("wrap".into(), "dropped".into(), false, true)]
16807 );
16808
16809 let addr = start_mock_rpc_server(failing_wrap_state()).await;
16810 let error = later_reconnect(restarted, format!("http://{addr}")).await;
16811 assert!(
16812 error.to_string().contains("did not increase by"),
16813 "was: {error}"
16814 );
16815 assert_eq!(
16816 execution_intent_markers(&admin_pool, &schema).await,
16817 vec![("wrap".into(), "dropped".into(), false, true)]
16818 );
16819
16820 drop_execution_schema(&admin_pool, &schema).await;
16821 }
16822
16823 #[tokio::test]
16824 async fn restart_wrap_revert_marks_terminal_and_releases() {
16825 let initial_state = broadcast_rpc_state()
16826 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16827 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16828 let Some((admin_pool, schema, mut first_client, _)) =
16829 execution_client_with_database("execution_restart_wrap_revert_test", initial_state)
16830 .await
16831 else {
16832 return;
16833 };
16834 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
16835 let error = first_client
16836 .wrap(U256::from(1_000_000_000_000_000_u64))
16837 .await
16838 .unwrap_err();
16839 assert!(
16840 error.to_string().contains("Timed out awaiting finality"),
16841 "was: {error}"
16842 );
16843 let database = first_client.cache.database.as_ref().unwrap().clone();
16844 let payload_keys = first_client.payload_keys.clone();
16845 drop(first_client);
16846
16847 let block = finalized_wrap_block(expected_hash);
16848 let receipt = receipt_with_transaction_hash(RECEIPT_REVERTED, expected_hash);
16849 let restart_state = with_finalized_identity(
16850 execution_rpc_state()
16851 .with_response("eth_getTransactionReceipt", &receipt)
16852 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block),
16853 &block,
16854 &receipt,
16855 );
16856 let addr = start_mock_rpc_server(restart_state.clone()).await;
16857 let mut restarted = test_client(format!("http://{addr}"));
16858 restarted.cache.database = Some(database);
16859 restarted.payload_keys = payload_keys;
16860 restarted.signer = Some(Arc::new(
16861 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16862 ));
16863
16864 restarted.reconcile_unresolved_execution().await.unwrap();
16865
16866 assert!(restarted.in_flight.lock().is_none());
16867 assert!(
16868 restart_state
16869 .recorded_requests()
16870 .iter()
16871 .all(|request| request["method"] != "eth_sendRawTransaction")
16872 );
16873 assert_eq!(
16874 execution_intent_markers(&admin_pool, &schema).await,
16875 vec![("wrap".into(), "reverted".into(), true, false)]
16876 );
16877
16878 drop_execution_schema(&admin_pool, &schema).await;
16879 }
16880
16881 #[tokio::test]
16882 async fn restart_quarantines_unretained_same_nonce_wrap_replacement() {
16883 let initial_state = broadcast_rpc_state()
16884 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16885 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16886 let Some((admin_pool, schema, mut first_client, _)) =
16887 execution_client_with_database("execution_restart_replacement_test", initial_state)
16888 .await
16889 else {
16890 return;
16891 };
16892 let original_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
16893 let error = first_client
16894 .wrap(U256::from(1_000_000_000_000_000_u64))
16895 .await
16896 .unwrap_err();
16897 assert!(
16898 error.to_string().contains("Timed out awaiting finality"),
16899 "was: {error}"
16900 );
16901 let database = first_client.cache.database.as_ref().unwrap().clone();
16902 let payload_keys = first_client.payload_keys.clone();
16903 drop(first_client);
16904
16905 let replacement_hash = B256::from([0x44; 32]);
16907 let restart_state = execution_rpc_state()
16908 .with_response("eth_getTransactionCount", TRANSACTION_COUNT_NEXT)
16909 .with_response_sequence("eth_call", &[CALL_BALANCE, CALL_BALANCE_AFTER_WRAP])
16910 .with_response_sequence(
16911 "eth_getTransactionReceipt",
16912 &[RECEIPT_NULL, RECEIPT_NULL, RECEIPT_NULL],
16913 )
16914 .with_parameter_response(
16915 "eth_getBlockByNumber",
16916 "0x1cf0d40",
16917 &replacement_head_block(replacement_hash),
16918 )
16919 .with_parameter_response(
16920 "eth_getBlockByNumber",
16921 "0x1cf0d41",
16922 &finalized_wrap_block(replacement_hash),
16923 );
16924 let addr = start_mock_rpc_server(restart_state.clone()).await;
16925 let mut restarted = test_client(format!("http://{addr}"));
16926 restarted.cache.database = Some(database);
16927 restarted.payload_keys = payload_keys;
16928 restarted.signer = Some(Arc::new(
16929 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16930 ));
16931 restarted.transaction_limits.receipt_timeout_secs = 2;
16932
16933 let error = restarted
16934 .reconcile_unresolved_execution()
16935 .await
16936 .unwrap_err();
16937
16938 let hashes: Vec<(String, String, bool)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
16939 "SELECT transaction_hash, status, current FROM \
16940 {schema}.execution_transaction_hash ORDER BY id"
16941 )))
16942 .fetch_all(&admin_pool)
16943 .await
16944 .unwrap();
16945 let requests = restart_state.recorded_requests();
16946
16947 assert!(
16948 error
16949 .to_string()
16950 .contains("has no authenticated retained payload"),
16951 "was: {error}"
16952 );
16953 assert_eq!(
16954 hashes,
16955 [(original_hash.to_string(), "dropped".to_string(), true)]
16956 );
16957 assert!(restarted.in_flight.lock().is_some());
16958 assert_eq!(
16959 execution_intent_markers(&admin_pool, &schema).await,
16960 vec![("wrap".into(), "dropped".into(), false, true)]
16961 );
16962 assert_eq!(
16963 requests
16964 .iter()
16965 .filter(|request| request["method"] == "eth_getTransactionReceipt")
16966 .count(),
16967 3
16968 );
16969 assert!(requests.iter().all(|request| {
16970 request["method"] != "eth_call" || request["params"][0]["data"] != "0xd0e30db0"
16971 }));
16972 assert!(
16973 requests
16974 .iter()
16975 .all(|request| request["method"] != "eth_sendRawTransaction")
16976 );
16977
16978 drop_execution_schema(&admin_pool, &schema).await;
16979 }
16980
16981 #[tokio::test]
16982 async fn replacement_scan_accepts_only_an_authenticated_retained_payload() {
16983 let expected_hash = expected_wrap_tx_hash(U256::from(1_u64)).await;
16984 let mut replacement_block: serde_json::Value =
16985 serde_json::from_str(&replacement_head_block(expected_hash)).unwrap();
16986 replacement_block["result"]["transactions"][1]["value"] = serde_json::json!("0x1");
16987 let replacement_block = replacement_block.to_string();
16988 let state = execution_rpc_state().with_parameter_response(
16989 "eth_getBlockByNumber",
16990 "0x1cf0d40",
16991 &replacement_block,
16992 );
16993 let Some((admin_pool, schema, client, _)) =
16994 execution_client_with_database("replacement_scan_authenticated", state).await
16995 else {
16996 return;
16997 };
16998 let database = client.cache.database.as_ref().unwrap();
16999 let (intent, persisted_hash, persisted_raw) =
17000 persist_test_wrap_broadcast(database, client.payload_keys.as_deref()).await;
17001 assert_eq!(persisted_hash, expected_hash);
17002 let authenticated_payloads = HashMap::from([(expected_hash, persisted_raw.clone())]);
17003 let head = VerifiedBlockHeader {
17004 number: FIXTURE_BLOCK,
17005 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
17006 parent_hash: B256::from_str(
17007 "0x0000000000000000000000000000000000000000000000000000000000000001",
17008 )
17009 .unwrap(),
17010 timestamp: FIXTURE_BLOCK_TIMESTAMP,
17011 base_fee_per_gas: Some(100_000_000),
17012 };
17013
17014 let matched = client
17015 .transaction_executor()
17016 .unwrap()
17017 .scan_canonical_replacement(&intent, 7, head, &authenticated_payloads)
17018 .await
17019 .unwrap()
17020 .unwrap();
17021
17022 assert_eq!(matched, (expected_hash, persisted_raw));
17023 let cursor: (i64, String) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
17024 "SELECT finalized_cursor_number, finalized_cursor_hash \
17025 FROM {schema}.execution_replacement_scan WHERE intent_id = $1"
17026 )))
17027 .bind(intent.id)
17028 .fetch_one(&admin_pool)
17029 .await
17030 .unwrap();
17031 assert_eq!(
17032 cursor,
17033 (FIXTURE_BLOCK as i64, FIXTURE_BLOCK_HASH.to_string())
17034 );
17035 let evidence_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17036 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
17037 WHERE intent_id = $1 AND decision_class = 'replacement_scan'"
17038 )))
17039 .bind(intent.id)
17040 .fetch_one(&admin_pool)
17041 .await
17042 .unwrap();
17043 assert_eq!(evidence_count, 2);
17044
17045 drop_execution_schema(&admin_pool, &schema).await;
17046 }
17047
17048 #[tokio::test]
17049 async fn restart_reconciles_finalized_approve_after_validation() {
17050 let initial_state = broadcast_rpc_state()
17051 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
17052 .with_response("eth_call", CALL_BOOL_TRUE)
17053 .with_call_response_sequence(ALLOWANCE_SELECTOR, &[CALL_ZERO; 3])
17054 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO);
17055 let Some((admin_pool, schema, mut first_client, _)) =
17056 execution_client_with_database("execution_restart_approve_test", initial_state).await
17057 else {
17058 return;
17059 };
17060 let expected_hash = expected_approve_tx_hash(U256::from(1_000u64)).await;
17061 let error = first_client
17062 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
17063 .await
17064 .unwrap_err();
17065 assert!(
17066 error.to_string().contains("Timed out awaiting finality"),
17067 "was: {error}"
17068 );
17069 let database = first_client.cache.database.as_ref().unwrap().clone();
17070 let payload_keys = first_client.payload_keys.clone();
17071 drop(first_client);
17072
17073 let block = finalized_approve_block(expected_hash, U256::from(1_000u64));
17074 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
17075 let restart_state = with_finalized_identity(
17076 execution_rpc_state()
17077 .with_response("eth_getTransactionReceipt", &receipt)
17078 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
17079 .with_call_response(ALLOWANCE_SELECTOR, CALL_ALLOWANCE_1000),
17080 &block,
17081 &receipt,
17082 );
17083 let addr = start_mock_rpc_server(restart_state.clone()).await;
17084 let mut restarted = test_client(format!("http://{addr}"));
17085 restarted.cache.database = Some(database);
17086 restarted.payload_keys = payload_keys;
17087 restarted.signer = Some(Arc::new(
17088 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
17089 ));
17090
17091 restarted.reconcile_unresolved_execution().await.unwrap();
17092
17093 let record = restarted
17094 .cache
17095 .get_execution_transaction(42161, &expected_hash.to_string())
17096 .await
17097 .unwrap()
17098 .unwrap();
17099 let requests = restart_state.recorded_requests();
17100 assert_eq!(record.status, "finalized");
17101 assert_eq!(record.purpose, "approve");
17102 assert!(restarted.in_flight.lock().is_none());
17103 assert_eq!(
17104 execution_intent_markers(&admin_pool, &schema).await,
17105 vec![("approve".into(), "finalized".into(), true, false)]
17106 );
17107 assert_eq!(
17108 requests
17109 .iter()
17110 .filter(|request| {
17111 request["method"] == "eth_call"
17112 && request["params"][0]["data"]
17113 .as_str()
17114 .is_some_and(|data| data.starts_with(ALLOWANCE_SELECTOR))
17115 })
17116 .count(),
17117 3
17118 );
17119 assert_eq!(
17120 requests
17121 .iter()
17122 .filter(|request| request["method"] == "eth_sendRawTransaction")
17123 .count(),
17124 0
17125 );
17126
17127 drop_execution_schema(&admin_pool, &schema).await;
17128 }
17129
17130 #[tokio::test]
17131 async fn restart_approve_postcondition_failure_keeps_signer_ownership() {
17132 let initial_state = broadcast_rpc_state()
17133 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
17134 .with_response("eth_call", CALL_BOOL_TRUE)
17135 .with_call_response_sequence(ALLOWANCE_SELECTOR, &[CALL_ZERO; 3])
17136 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO);
17137 let Some((admin_pool, schema, mut first_client, _)) =
17138 execution_client_with_database("execution_restart_approve_post_test", initial_state)
17139 .await
17140 else {
17141 return;
17142 };
17143 let expected_hash = expected_approve_tx_hash(U256::from(1_000u64)).await;
17144 let error = first_client
17145 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
17146 .await
17147 .unwrap_err();
17148 assert!(
17149 error.to_string().contains("Timed out awaiting finality"),
17150 "was: {error}"
17151 );
17152 let database = first_client.cache.database.as_ref().unwrap().clone();
17153 let payload_keys = first_client.payload_keys.clone();
17154 drop(first_client);
17155
17156 let block = finalized_approve_block(expected_hash, U256::from(1_000u64));
17158 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
17159 let restart_state = with_finalized_identity(
17160 execution_rpc_state()
17161 .with_response("eth_getTransactionReceipt", &receipt)
17162 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
17163 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO),
17164 &block,
17165 &receipt,
17166 );
17167 let addr = start_mock_rpc_server(restart_state.clone()).await;
17168 let mut restarted = test_client(format!("http://{addr}"));
17169 restarted.cache.database = Some(database);
17170 restarted.payload_keys = payload_keys;
17171 restarted.signer = Some(Arc::new(
17172 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
17173 ));
17174
17175 let error = restarted
17176 .reconcile_unresolved_execution()
17177 .await
17178 .unwrap_err();
17179
17180 assert!(
17181 error
17182 .to_string()
17183 .contains("does not equal the requested amount"),
17184 "was: {error}"
17185 );
17186 let in_flight = awaiting_in_flight(&restarted);
17187 assert_eq!(in_flight.nonce, 7);
17188 assert_eq!(in_flight.purpose, TransactionPurpose::Approve);
17189 assert!(
17190 restart_state
17191 .recorded_requests()
17192 .iter()
17193 .all(|request| request["method"] != "eth_sendRawTransaction")
17194 );
17195 assert_eq!(
17196 execution_intent_markers(&admin_pool, &schema).await,
17197 vec![("approve".into(), "dropped".into(), false, true)]
17198 );
17199
17200 let error = later_reconnect(restarted, format!("http://{addr}")).await;
17201 assert!(
17202 error
17203 .to_string()
17204 .contains("does not equal the requested amount"),
17205 "was: {error}"
17206 );
17207 assert_eq!(
17208 execution_intent_markers(&admin_pool, &schema).await,
17209 vec![("approve".into(), "dropped".into(), false, true)]
17210 );
17211
17212 drop_execution_schema(&admin_pool, &schema).await;
17213 }
17214
17215 #[tokio::test]
17216 async fn disappearing_unfinalized_receipt_drops_without_committing_inclusion() {
17217 let state = execution_rpc_state()
17218 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
17219 .with_response("eth_estimateGas", ESTIMATE_GAS)
17220 .with_response("eth_call", CALL_BALANCE)
17221 .with_response_sequence(
17222 "eth_getTransactionReceipt",
17223 &[
17224 RECEIPT_SUCCESS,
17225 RECEIPT_SUCCESS,
17226 RECEIPT_SUCCESS,
17227 RECEIPT_NULL,
17228 RECEIPT_NULL,
17229 RECEIPT_NULL,
17230 ],
17231 )
17232 .with_parameter_response("eth_getBlockByNumber", "finalized", BLOCK_BY_NUMBER)
17233 .with_send_raw_transaction_echo();
17234 let Some((admin_pool, schema, mut client, _)) =
17235 execution_client_with_database("execution_receipt_disappeared_test", state).await
17236 else {
17237 return;
17238 };
17239 client.transaction_limits.receipt_timeout_secs = 2;
17240
17241 let error = client
17242 .wrap(U256::from(1_000_000_000_000_000_u64))
17243 .await
17244 .unwrap_err();
17245 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17246 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
17247 )))
17248 .fetch_all(&admin_pool)
17249 .await
17250 .unwrap();
17251
17252 assert!(
17253 error.to_string().contains("Timed out awaiting finality"),
17254 "was: {error}"
17255 );
17256 assert_eq!(transitions, ["prepared", "signed", "broadcast", "dropped"]);
17257 assert!(client.in_flight.lock().is_some());
17258
17259 drop_execution_schema(&admin_pool, &schema).await;
17260 }
17261
17262 #[tokio::test]
17263 async fn changed_unfinalized_block_drops_without_committing_inclusion() {
17264 let changed_block = serde_json::json!({
17265 "jsonrpc": "2.0",
17266 "id": 1,
17267 "result": {
17268 "number": "0x1cf0d41",
17269 "hash": "0x4444444444444444444444444444444444444444444444444444444444444444",
17270 "parentHash": FIXTURE_BLOCK_HASH,
17271 "timestamp": "0x69044a21",
17272 "baseFeePerGas": "0x5f5e100",
17273 "transactions": []
17274 }
17275 })
17276 .to_string();
17277 let state = execution_rpc_state()
17278 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
17279 .with_response("eth_estimateGas", ESTIMATE_GAS)
17280 .with_response("eth_call", CALL_BALANCE)
17281 .with_response_sequence(
17282 "eth_getTransactionReceipt",
17283 &[
17284 RECEIPT_SUCCESS,
17285 RECEIPT_SUCCESS,
17286 RECEIPT_SUCCESS,
17287 RECEIPT_SUCCESS,
17288 RECEIPT_SUCCESS,
17289 RECEIPT_SUCCESS,
17290 ],
17291 )
17292 .with_parameter_response_sequence(
17293 "eth_getBlockByNumber",
17294 "0x1cf0d41",
17295 &[
17296 BLOCK_CANONICAL,
17297 BLOCK_CANONICAL,
17298 BLOCK_CANONICAL,
17299 &changed_block,
17300 &changed_block,
17301 &changed_block,
17302 ],
17303 )
17304 .with_parameter_response("eth_getBlockByNumber", "finalized", BLOCK_BY_NUMBER)
17305 .with_send_raw_transaction_echo();
17306 let Some((admin_pool, schema, mut client, _)) =
17307 execution_client_with_database("execution_reorg_test", state).await
17308 else {
17309 return;
17310 };
17311 client.transaction_limits.receipt_timeout_secs = 2;
17312
17313 let error = client
17314 .wrap(U256::from(1_000_000_000_000_000_u64))
17315 .await
17316 .unwrap_err();
17317 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17318 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
17319 )))
17320 .fetch_all(&admin_pool)
17321 .await
17322 .unwrap();
17323
17324 assert!(
17325 error.to_string().contains("Timed out awaiting finality"),
17326 "was: {error}"
17327 );
17328 assert_eq!(transitions, ["prepared", "signed", "broadcast", "dropped"]);
17329 assert!(client.in_flight.lock().is_some());
17330
17331 drop_execution_schema(&admin_pool, &schema).await;
17332 }
17333
17334 #[tokio::test]
17335 async fn pre_sign_pending_nonce_drift_blocks_wrap_before_signature() {
17336 let state = execution_rpc_state()
17337 .with_response_sequence(
17338 "eth_getTransactionCount",
17339 &[
17340 TRANSACTION_COUNT,
17341 TRANSACTION_COUNT,
17342 TRANSACTION_COUNT,
17343 TRANSACTION_COUNT_NEXT,
17344 TRANSACTION_COUNT_NEXT,
17345 TRANSACTION_COUNT_NEXT,
17346 ],
17347 )
17348 .with_response("eth_estimateGas", ESTIMATE_GAS)
17349 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE)
17350 .with_send_raw_transaction_echo();
17351 let Some((admin_pool, schema, mut client, state)) =
17352 execution_client_with_database("pre_sign_pending_nonce_drift", state).await
17353 else {
17354 return;
17355 };
17356
17357 let error = client
17358 .wrap(U256::from(1_000_000_000_000_000_u64))
17359 .await
17360 .unwrap_err();
17361 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17362 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
17363 )))
17364 .fetch_one(&admin_pool)
17365 .await
17366 .unwrap();
17367
17368 assert!(
17369 error
17370 .to_string()
17371 .contains("Pending nonce does not match the verified canonical nonce"),
17372 "was: {error}"
17373 );
17374 assert_eq!(signed_count, 0);
17375 assert!(
17376 state
17377 .recorded_requests()
17378 .iter()
17379 .all(|request| request["method"] != "eth_sendRawTransaction")
17380 );
17381 assert!(client.in_flight.lock().is_none());
17382
17383 drop_execution_schema(&admin_pool, &schema).await;
17384 }
17385
17386 #[tokio::test]
17387 async fn unknown_same_nonce_swap_replacement_emits_no_rejection() {
17388 let replacement_hash = B256::from([0x44; 32]);
17389 let replacement_block = replacement_head_block(replacement_hash);
17390 let state = execution_rpc_state()
17391 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d40", &replacement_block)
17392 .with_send_raw_transaction_echo();
17393 let Some((admin_pool, schema, mut client, _, _)) =
17394 swap_client_with_database("unknown_same_nonce_swap_replacement", state).await
17395 else {
17396 return;
17397 };
17398 let mut receiver = start_with_events(&mut client);
17399 let database = client.cache.database.as_ref().unwrap();
17400 let (intent, original_hash, original_payload) =
17401 persist_test_swap_broadcast(database, client.payload_keys.as_deref()).await;
17402 let authenticated_payloads = HashMap::from([(original_hash, original_payload)]);
17403 let head = VerifiedBlockHeader {
17404 number: FIXTURE_BLOCK,
17405 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
17406 parent_hash: B256::from_str(
17407 "0x0000000000000000000000000000000000000000000000000000000000000001",
17408 )
17409 .unwrap(),
17410 timestamp: FIXTURE_BLOCK_TIMESTAMP,
17411 base_fee_per_gas: Some(100_000_000),
17412 };
17413
17414 let error = client
17415 .transaction_executor()
17416 .unwrap()
17417 .scan_canonical_replacement(&intent, 7, head, &authenticated_payloads)
17418 .await
17419 .unwrap_err();
17420 let events = collect_order_events(&mut receiver);
17421 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
17422 "SELECT status, active FROM {schema}.execution_intent"
17423 )))
17424 .fetch_one(&admin_pool)
17425 .await
17426 .unwrap();
17427
17428 assert!(
17429 error
17430 .to_string()
17431 .contains("has no authenticated retained payload"),
17432 "was: {error}"
17433 );
17434 assert!(events.is_empty(), "was: {events:?}");
17435 assert_eq!(status, "broadcast");
17436 assert!(active);
17437
17438 drop_execution_schema(&admin_pool, &schema).await;
17439 }
17440
17441 #[tokio::test]
17442 async fn replacement_scan_rejects_rpc_fields_that_conflict_with_the_payload() {
17443 let expected_hash = expected_wrap_tx_hash(U256::from(1_u64)).await;
17444 let mut replacement_block: serde_json::Value =
17445 serde_json::from_str(&replacement_head_block(expected_hash)).unwrap();
17446 replacement_block["result"]["transactions"][1]["value"] = serde_json::json!("0x2");
17447 let replacement_block = replacement_block.to_string();
17448 let state = execution_rpc_state().with_parameter_response(
17449 "eth_getBlockByNumber",
17450 "0x1cf0d40",
17451 &replacement_block,
17452 );
17453 let Some((admin_pool, schema, client, _)) =
17454 execution_client_with_database("replacement_scan_payload_mismatch", state).await
17455 else {
17456 return;
17457 };
17458 let database = client.cache.database.as_ref().unwrap();
17459 let (intent, persisted_hash, persisted_payload) =
17460 persist_test_wrap_broadcast(database, client.payload_keys.as_deref()).await;
17461 assert_eq!(persisted_hash, expected_hash);
17462 let authenticated_payloads = HashMap::from([(persisted_hash, persisted_payload)]);
17463 let head = VerifiedBlockHeader {
17464 number: FIXTURE_BLOCK,
17465 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
17466 parent_hash: B256::from_str(
17467 "0x0000000000000000000000000000000000000000000000000000000000000001",
17468 )
17469 .unwrap(),
17470 timestamp: FIXTURE_BLOCK_TIMESTAMP,
17471 base_fee_per_gas: Some(100_000_000),
17472 };
17473
17474 let error = client
17475 .transaction_executor()
17476 .unwrap()
17477 .scan_canonical_replacement(&intent, 7, head, &authenticated_payloads)
17478 .await
17479 .unwrap_err();
17480 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
17481 "SELECT status, active FROM {schema}.execution_intent"
17482 )))
17483 .fetch_one(&admin_pool)
17484 .await
17485 .unwrap();
17486
17487 assert!(
17488 error
17489 .to_string()
17490 .contains("failed authenticated payload validation"),
17491 "was: {error}"
17492 );
17493 assert_eq!(status, "broadcast");
17494 assert!(active);
17495
17496 drop_execution_schema(&admin_pool, &schema).await;
17497 }
17498
17499 #[tokio::test]
17500 async fn execution_transaction_constraints_reject_conflicting_identity() {
17501 const TRANSACTION_HASH: &str = "0xduplicate-transaction-hash";
17502 const OTHER_WALLET: &str = "0x0000000000000000000000000000000000000001";
17503 let Some((admin_pool, schema, client, _)) = execution_client_with_unprotected_database(
17504 "execution_duplicate_record_test",
17505 ready_rpc_state(),
17506 )
17507 .await
17508 else {
17509 return;
17510 };
17511 let database = client.cache.database.as_ref().unwrap();
17512 let operator = database
17513 .reserve_execution_intent(&ExecutionIntentInsert {
17514 chain_id: 42161,
17515 wallet_address: WALLET.to_string(),
17516 purpose: "wrap".to_string(),
17517 client_order_id: None,
17518 trader_id: None,
17519 strategy_id: None,
17520 account_id: None,
17521 instrument_id: None,
17522 pool_address: None,
17523 transaction_to: WETH_ADDRESS.to_string(),
17524 transaction_input: "0xd0e30db0".to_string(),
17525 transaction_value: "1".to_string(),
17526 amount_in: None,
17527 created_block: FIXTURE_BLOCK,
17528 })
17529 .await
17530 .unwrap();
17531 database
17532 .assign_execution_intent_nonce(operator.id, 7)
17533 .await
17534 .unwrap();
17535 database
17536 .add_execution_transaction_hash(operator.id, 42161, TRANSACTION_HASH, &[1, 2, 3])
17537 .await
17538 .unwrap();
17539 database
17540 .add_execution_transaction_hash(operator.id, 42161, TRANSACTION_HASH, &[1, 2, 3])
17541 .await
17542 .unwrap();
17543
17544 let signer_conflict = database
17545 .reserve_execution_intent(&ExecutionIntentInsert {
17546 chain_id: 42161,
17547 wallet_address: WALLET.to_string(),
17548 purpose: "approve".to_string(),
17549 client_order_id: None,
17550 trader_id: None,
17551 strategy_id: None,
17552 account_id: None,
17553 instrument_id: None,
17554 pool_address: None,
17555 transaction_to: ROUTER_ADDRESS.to_string(),
17556 transaction_input: "0x01".to_string(),
17557 transaction_value: "0".to_string(),
17558 amount_in: None,
17559 created_block: FIXTURE_BLOCK,
17560 })
17561 .await
17562 .unwrap_err();
17563 let other = database
17564 .reserve_execution_intent(&ExecutionIntentInsert {
17565 chain_id: 42161,
17566 wallet_address: OTHER_WALLET.to_string(),
17567 purpose: "approve".to_string(),
17568 client_order_id: None,
17569 trader_id: None,
17570 strategy_id: None,
17571 account_id: None,
17572 instrument_id: None,
17573 pool_address: None,
17574 transaction_to: ROUTER_ADDRESS.to_string(),
17575 transaction_input: "0x01".to_string(),
17576 transaction_value: "0".to_string(),
17577 amount_in: None,
17578 created_block: FIXTURE_BLOCK,
17579 })
17580 .await
17581 .unwrap();
17582 database
17583 .assign_execution_intent_nonce(other.id, 7)
17584 .await
17585 .unwrap();
17586 let hash_conflict = database
17587 .add_execution_transaction_hash(other.id, 42161, TRANSACTION_HASH, &[4, 5, 6])
17588 .await
17589 .unwrap_err();
17590
17591 let record = database
17592 .get_execution_transaction(42161, TRANSACTION_HASH)
17593 .await
17594 .unwrap()
17595 .unwrap();
17596 let count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17597 "SELECT COUNT(*) FROM {schema}.execution_intent"
17598 )))
17599 .fetch_one(&admin_pool)
17600 .await
17601 .unwrap();
17602
17603 assert_eq!(record.wallet_address.as_deref(), Some(WALLET));
17604 assert_eq!(record.nonce, 7);
17605 assert_eq!(record.transaction_hash, TRANSACTION_HASH);
17606 assert_eq!(record.purpose, "wrap");
17607 assert_eq!(record.status, "signed");
17608 assert_eq!(record.client_order_id, None);
17609 assert!(
17610 hash_conflict
17611 .to_string()
17612 .contains("conflicts with its persisted identity"),
17613 "was: {hash_conflict}"
17614 );
17615 assert_eq!(
17616 signer_conflict.to_string(),
17617 "Execution intent reservation failed before commit"
17618 );
17619 assert!(
17620 signer_conflict.chain().any(|cause| cause
17621 .to_string()
17622 .contains("execution_intent_active_signer_key")),
17623 "was: {signer_conflict:#}"
17624 );
17625 assert_eq!(count, 2);
17626
17627 drop_execution_schema(&admin_pool, &schema).await;
17628 }
17629
17630 #[tokio::test]
17631 async fn execution_status_transitions_are_idempotent() {
17632 const TRANSACTION_HASH: &str =
17633 "0x5555555555555555555555555555555555555555555555555555555555555555";
17634 let Some((admin_pool, schema, client, _)) = execution_client_with_unprotected_database(
17635 "execution_transition_test",
17636 ready_rpc_state(),
17637 )
17638 .await
17639 else {
17640 return;
17641 };
17642 let database = client.cache.database.as_ref().unwrap();
17643 let intent = database
17644 .reserve_execution_intent(&ExecutionIntentInsert {
17645 chain_id: 42161,
17646 wallet_address: WALLET.to_string(),
17647 purpose: "wrap".to_string(),
17648 client_order_id: None,
17649 trader_id: None,
17650 strategy_id: None,
17651 account_id: None,
17652 instrument_id: None,
17653 pool_address: None,
17654 transaction_to: WETH_ADDRESS.to_string(),
17655 transaction_input: "0xd0e30db0".to_string(),
17656 transaction_value: "1".to_string(),
17657 amount_in: None,
17658 created_block: FIXTURE_BLOCK,
17659 })
17660 .await
17661 .unwrap();
17662 database
17663 .assign_execution_intent_nonce(intent.id, 7)
17664 .await
17665 .unwrap();
17666 database
17667 .add_execution_transaction_hash(intent.id, 42161, TRANSACTION_HASH, &[1, 2, 3])
17668 .await
17669 .unwrap();
17670
17671 for _ in 0..2 {
17672 database
17673 .record_execution_status(
17674 intent.id,
17675 TRANSACTION_HASH,
17676 TransactionStatus::Broadcast,
17677 None,
17678 None,
17679 None,
17680 None,
17681 None,
17682 )
17683 .await
17684 .unwrap();
17685 }
17686
17687 for status in [TransactionStatus::Included, TransactionStatus::Included] {
17688 database
17689 .record_execution_status(
17690 intent.id,
17691 TRANSACTION_HASH,
17692 status,
17693 Some(FIXTURE_BLOCK + 1),
17694 Some("0x2222222222222222222222222222222222222222222222222222222222222222"),
17695 Some(true),
17696 Some(50_112),
17697 Some("100000000"),
17698 )
17699 .await
17700 .unwrap();
17701 }
17702
17703 for _ in 0..2 {
17704 database
17705 .record_execution_status(
17706 intent.id,
17707 TRANSACTION_HASH,
17708 TransactionStatus::Finalized,
17709 Some(FIXTURE_BLOCK + 1),
17710 Some("0x2222222222222222222222222222222222222222222222222222222222222222"),
17711 Some(true),
17712 Some(50_112),
17713 Some("100000000"),
17714 )
17715 .await
17716 .unwrap();
17717 }
17718
17719 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17720 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
17721 )))
17722 .fetch_all(&admin_pool)
17723 .await
17724 .unwrap();
17725 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
17726 "SELECT status, active FROM {schema}.execution_intent"
17727 )))
17728 .fetch_one(&admin_pool)
17729 .await
17730 .unwrap();
17731 let append_only_error = sqlx::query(sqlx::AssertSqlSafe(format!(
17732 "DELETE FROM {schema}.execution_transaction_transition WHERE intent_id = {}",
17733 intent.id
17734 )))
17735 .execute(&admin_pool)
17736 .await
17737 .unwrap_err();
17738
17739 assert_eq!(
17740 transitions,
17741 ["prepared", "signed", "broadcast", "included", "finalized"]
17742 );
17743 assert_eq!(status, "finalized");
17744 assert!(active);
17745 assert!(
17746 append_only_error
17747 .to_string()
17748 .contains("Execution transitions are append-only"),
17749 "was: {append_only_error}"
17750 );
17751
17752 drop_execution_schema(&admin_pool, &schema).await;
17753 }
17754
17755 #[tokio::test]
17756 async fn wrap_then_approve_persists_records_and_clears_in_flight() {
17757 let state = execution_rpc_state()
17758 .with_parameter_response_sequence(
17759 "eth_getBlockByNumber",
17760 "latest",
17761 &[
17762 BLOCK_BY_NUMBER,
17763 BLOCK_BY_NUMBER,
17764 BLOCK_BY_NUMBER,
17765 BLOCK_FINALIZED,
17766 BLOCK_FINALIZED,
17767 BLOCK_FINALIZED,
17768 ],
17769 )
17770 .with_response_sequence(
17771 "eth_call",
17772 &[
17773 CALL_BALANCE,
17774 CALL_BALANCE,
17775 CALL_BALANCE,
17776 CALL_BALANCE,
17777 CALL_BALANCE,
17778 CALL_BALANCE,
17779 CALL_BALANCE_AFTER_WRAP,
17780 CALL_BALANCE_AFTER_WRAP,
17781 CALL_BALANCE_AFTER_WRAP,
17782 CALL_BOOL_TRUE,
17783 CALL_BOOL_TRUE,
17784 CALL_BOOL_TRUE,
17785 ],
17786 )
17787 .with_call_response_sequence(
17788 ALLOWANCE_SELECTOR,
17789 &[
17790 CALL_ZERO,
17791 CALL_ZERO,
17792 CALL_ZERO,
17793 CALL_ALLOWANCE_MAX,
17794 CALL_ALLOWANCE_MAX,
17795 CALL_ALLOWANCE_MAX,
17796 ],
17797 )
17798 .with_response_sequence(
17799 "eth_getTransactionCount",
17800 &[
17801 TRANSACTION_COUNT,
17802 TRANSACTION_COUNT,
17803 TRANSACTION_COUNT,
17804 TRANSACTION_COUNT,
17805 TRANSACTION_COUNT,
17806 TRANSACTION_COUNT,
17807 TRANSACTION_COUNT_NEXT,
17808 TRANSACTION_COUNT_NEXT,
17809 TRANSACTION_COUNT_NEXT,
17810 TRANSACTION_COUNT_NEXT,
17811 TRANSACTION_COUNT_NEXT,
17812 TRANSACTION_COUNT_NEXT,
17813 ],
17814 )
17815 .with_response("eth_estimateGas", ESTIMATE_GAS)
17816 .with_response_sequence(
17817 "eth_getTransactionReceipt",
17818 &[
17819 RECEIPT_SUCCESS,
17820 RECEIPT_SUCCESS,
17821 RECEIPT_SUCCESS,
17822 RECEIPT_SUCCESS,
17823 RECEIPT_SUCCESS,
17824 RECEIPT_SUCCESS,
17825 ],
17826 )
17827 .with_send_raw_transaction_echo();
17828 let Some((admin_pool, schema, mut client, state)) =
17829 execution_client_with_database("execution_client_test", state).await
17830 else {
17831 return;
17832 };
17833 client.config.unlimited_approval = true;
17834 client.transaction_limits.receipt_timeout_secs = 3;
17835
17836 let wrap_hash = client
17837 .wrap(U256::from(1_000_000_000_000_000u64))
17838 .await
17839 .unwrap();
17840
17841 let record = client
17842 .cache
17843 .get_execution_transaction(42161, &wrap_hash.to_string())
17844 .await
17845 .unwrap()
17846 .unwrap();
17847 assert_eq!(record.nonce, 7);
17848 assert_eq!(record.purpose, "wrap");
17849 assert_eq!(record.status, "finalized");
17850 assert_eq!(
17851 execution_intent_markers(&admin_pool, &schema).await,
17852 vec![("wrap".into(), "finalized".into(), true, false)]
17853 );
17854
17855 let approve_hash = client
17856 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
17857 .await
17858 .unwrap();
17859
17860 let record = client
17861 .cache
17862 .get_execution_transaction(42161, &approve_hash.to_string())
17863 .await
17864 .unwrap()
17865 .unwrap();
17866 assert_eq!(record.nonce, 8);
17867 assert_eq!(record.purpose, "approve");
17868 assert_eq!(record.status, "finalized");
17869 assert_eq!(
17870 execution_intent_markers(&admin_pool, &schema).await,
17871 vec![
17872 ("wrap".into(), "finalized".into(), true, false),
17873 ("approve".into(), "finalized".into(), true, false),
17874 ]
17875 );
17876
17877 let requests = state.recorded_requests();
17878 let broadcasts: Vec<_> = requests
17879 .iter()
17880 .filter(|request| request["method"] == "eth_sendRawTransaction")
17881 .collect();
17882 assert_eq!(broadcasts.len(), 2);
17883 for broadcast in &broadcasts {
17884 let payload = broadcast["params"][0].as_str().unwrap();
17885 assert!(payload.starts_with("0x02"), "was: {payload}");
17886 }
17887 let broadcast_indexes = requests
17888 .iter()
17889 .enumerate()
17890 .filter_map(|(index, request)| {
17891 (request["method"] == "eth_sendRawTransaction").then_some(index)
17892 })
17893 .collect::<Vec<_>>();
17894
17895 for (index, expected_block) in broadcast_indexes
17896 .into_iter()
17897 .zip([FIXTURE_BLOCK_PARAM, "0x1cf0d42"])
17898 {
17899 let nonce_fence = &requests[index - 6..index];
17900 assert!(nonce_fence[..3].iter().all(|request| {
17901 request["method"] == "eth_getTransactionCount"
17902 && request["params"][1] == expected_block
17903 }));
17904 assert!(nonce_fence[3..].iter().all(|request| {
17905 request["method"] == "eth_getTransactionCount" && request["params"][1] == "pending"
17906 }));
17907 }
17908 let receipt_polls = requests
17909 .iter()
17910 .filter(|request| request["method"] == "eth_getTransactionReceipt")
17911 .count();
17912 assert_eq!(receipt_polls, 6);
17913 let allowance_calls: Vec<_> = requests
17914 .iter()
17915 .filter(|request| {
17916 request["method"] == "eth_call"
17917 && request["params"][0]["data"]
17918 .as_str()
17919 .is_some_and(|data| data.starts_with(ALLOWANCE_SELECTOR))
17920 })
17921 .collect();
17922 assert_eq!(allowance_calls.len(), 6);
17923 assert!(
17924 allowance_calls[..3]
17925 .iter()
17926 .all(|request| request["params"][1] == "0x1cf0d42")
17927 );
17928 assert!(
17929 allowance_calls[3..]
17930 .iter()
17931 .all(|request| request["params"][1] == "0x1cf0d41")
17932 );
17933
17934 let estimates: Vec<_> = requests
17936 .iter()
17937 .filter(|request| request["method"] == "eth_estimateGas")
17938 .collect();
17939 assert_eq!(estimates.len(), 6);
17940 assert!(estimates[..3].iter().all(|request| {
17941 request["params"].as_array().unwrap().len() == 2
17942 && request["params"][1] == FIXTURE_BLOCK_PARAM
17943 }));
17944 assert!(estimates[3..].iter().all(|request| {
17945 request["params"].as_array().unwrap().len() == 2 && request["params"][1] == "0x1cf0d42"
17946 }));
17947 let approve_data = estimates[3]["params"][0]["data"].as_str().unwrap();
17948 assert!(
17949 approve_data.starts_with("0x095ea7b3"),
17950 "was: {approve_data}"
17951 );
17952 assert!(
17953 approve_data.ends_with(&"f".repeat(64)),
17954 "was: {approve_data}"
17955 );
17956
17957 for selector in [FACTORY_SELECTOR, WETH9_SELECTOR] {
17958 let calls: Vec<_> = requests
17959 .iter()
17960 .filter(|request| {
17961 request["method"] == "eth_call"
17962 && request["params"][0]["data"]
17963 .as_str()
17964 .is_some_and(|data| data.starts_with(selector))
17965 })
17966 .collect();
17967 assert!(!calls.is_empty(), "selector {selector}");
17968 assert!(
17969 calls.iter().all(|request| request["params"][1]
17970 .as_str()
17971 .is_some_and(|block| block.starts_with("0x"))),
17972 "selector {selector}: {calls:?}"
17973 );
17974 }
17975 let latest_blocks = requests
17976 .iter()
17977 .filter(|request| {
17978 request["method"] == "eth_getBlockByNumber"
17979 && request["params"] == serde_json::json!(["latest", false])
17980 })
17981 .count();
17982 assert_eq!(latest_blocks, 6);
17983
17984 drop_execution_schema(&admin_pool, &schema).await;
17985 }
17986
17987 #[tokio::test]
17988 async fn reverted_receipt_marks_record_reverted_and_errors() {
17989 let state = signing_rpc_state()
17990 .with_response("eth_getTransactionReceipt", RECEIPT_REVERTED)
17991 .with_send_raw_transaction_echo();
17992 let Some((admin_pool, schema, mut client, _)) =
17993 execution_client_with_database("execution_reverted_test", state).await
17994 else {
17995 return;
17996 };
17997
17998 let error = client
17999 .wrap(U256::from(1_000_000_000_000_000u64))
18000 .await
18001 .unwrap_err();
18002
18003 assert!(
18004 error.to_string().contains("reverted on-chain"),
18005 "was: {error}"
18006 );
18007 assert!(client.in_flight.lock().is_none());
18008
18009 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000u64)).await;
18010
18011 let record = client
18012 .cache
18013 .get_execution_transaction(42161, &expected_hash.to_string())
18014 .await
18015 .unwrap()
18016 .unwrap();
18017 assert_eq!(record.status, "reverted");
18018 assert_eq!(
18019 execution_intent_markers(&admin_pool, &schema).await,
18020 vec![("wrap".into(), "reverted".into(), true, false)]
18021 );
18022
18023 drop_execution_schema(&admin_pool, &schema).await;
18024 }
18025
18026 fn market_sell_order_with_id(instrument_id: InstrumentId, client_order_id: &str) -> OrderAny {
18027 OrderTestBuilder::new(OrderType::Market)
18028 .trader_id(TraderId::from("TRADER-001"))
18029 .strategy_id(StrategyId::from("S-001"))
18030 .instrument_id(instrument_id)
18031 .client_order_id(ClientOrderId::from(client_order_id))
18032 .side(OrderSide::Sell)
18033 .quantity(Quantity::from("0.001"))
18034 .build()
18035 }
18036
18037 fn market_buy_order_with_id(instrument_id: InstrumentId, client_order_id: &str) -> OrderAny {
18038 OrderTestBuilder::new(OrderType::Market)
18039 .trader_id(TraderId::from("TRADER-001"))
18040 .strategy_id(StrategyId::from("S-001"))
18041 .instrument_id(instrument_id)
18042 .client_order_id(ClientOrderId::from(client_order_id))
18043 .side(OrderSide::Buy)
18044 .quantity(Quantity::from("0.001"))
18045 .build()
18046 }
18047
18048 fn submit_order_list_cmd(orders: &[OrderAny]) -> SubmitOrderList {
18049 let order_list = OrderList::new(
18050 OrderListId::from("OL-001"),
18051 orders[0].instrument_id(),
18052 orders[0].strategy_id(),
18053 orders.iter().map(|order| order.client_order_id()).collect(),
18054 UnixNanos::default(),
18055 );
18056 SubmitOrderList::new(
18057 TraderId::from("TRADER-001"),
18058 Some(ClientId::from("BLOCKCHAIN-001")),
18059 orders[0].strategy_id(),
18060 order_list,
18061 orders
18062 .iter()
18063 .map(|order| order.init_event().clone())
18064 .collect(),
18065 None,
18066 None,
18067 None,
18068 UUID4::new(),
18069 UnixNanos::default(),
18070 None,
18071 )
18072 }
18073
18074 fn modify_order_cmd(
18075 instrument_id: InstrumentId,
18076 client_order_id: ClientOrderId,
18077 ) -> ModifyOrder {
18078 ModifyOrder::new(
18079 TraderId::from("TRADER-001"),
18080 Some(ClientId::from("BLOCKCHAIN-001")),
18081 StrategyId::from("S-001"),
18082 instrument_id,
18083 client_order_id,
18084 None,
18085 Some(Quantity::from("0.002")),
18086 None,
18087 None,
18088 UUID4::new(),
18089 UnixNanos::default(),
18090 None,
18091 None,
18092 )
18093 }
18094
18095 fn cancel_order_cmd(
18096 instrument_id: InstrumentId,
18097 client_order_id: ClientOrderId,
18098 ) -> CancelOrder {
18099 CancelOrder::new(
18100 TraderId::from("TRADER-001"),
18101 Some(ClientId::from("BLOCKCHAIN-001")),
18102 StrategyId::from("S-001"),
18103 instrument_id,
18104 client_order_id,
18105 None,
18106 UUID4::new(),
18107 UnixNanos::default(),
18108 None,
18109 None,
18110 )
18111 }
18112
18113 fn cancel_all_orders_cmd(instrument_id: InstrumentId) -> CancelAllOrders {
18114 CancelAllOrders::new(
18115 TraderId::from("TRADER-001"),
18116 Some(ClientId::from("BLOCKCHAIN-001")),
18117 StrategyId::from("S-001"),
18118 instrument_id,
18119 Some(OrderSide::Sell),
18120 UUID4::new(),
18121 UnixNanos::default(),
18122 None,
18123 None,
18124 )
18125 }
18126
18127 fn batch_cancel_orders_cmd(cancels: Vec<CancelOrder>) -> BatchCancelOrders {
18128 BatchCancelOrders::new(
18129 TraderId::from("TRADER-001"),
18130 Some(ClientId::from("BLOCKCHAIN-001")),
18131 StrategyId::from("S-001"),
18132 cancels[0].instrument_id,
18133 cancels,
18134 UUID4::new(),
18135 UnixNanos::default(),
18136 None,
18137 None,
18138 )
18139 }
18140
18141 fn query_order_cmd(instrument_id: InstrumentId, client_order_id: ClientOrderId) -> QueryOrder {
18142 QueryOrder::new(
18143 TraderId::from("TRADER-001"),
18144 Some(ClientId::from("BLOCKCHAIN-001")),
18145 StrategyId::from("S-001"),
18146 instrument_id,
18147 client_order_id,
18148 None,
18149 UUID4::new(),
18150 UnixNanos::default(),
18151 None,
18152 None,
18153 )
18154 }
18155
18156 async fn unsupported_client_with_mock_rpc()
18157 -> (BlockchainExecutionClient, MockRpcState, Rc<RefCell<Cache>>) {
18158 let state = ready_rpc_state();
18159 let addr = start_mock_rpc_server(state.clone()).await;
18160 let (client, cache) = swap_client_with_cache(test_config(format!("http://{addr}")));
18161 (client, state, cache)
18162 }
18163
18164 #[tokio::test]
18165 async fn submit_order_list_denies_every_order_without_side_effects() {
18166 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18167 let pool = test_pool();
18168 let first = test_market_sell_order(pool.instrument_id);
18169 let second = market_sell_order_with_id(pool.instrument_id, "O-SWAP-002");
18170 cache
18171 .borrow_mut()
18172 .add_order(second.clone(), None, None, false)
18173 .unwrap();
18174 let orders = [first.clone(), second.clone()];
18175 let mut receiver = start_with_events(&mut client);
18176
18177 client
18178 .submit_order_list(submit_order_list_cmd(&orders))
18179 .unwrap();
18180
18181 let events = collect_order_events(&mut receiver);
18182 let mut denied_ids = Vec::new();
18183
18184 for event in &events {
18185 let OrderEventAny::Denied(denied) = event else {
18186 panic!("expected OrderDenied, was {event:?}");
18187 };
18188 assert_eq!(denied.reason.as_str(), ORDER_LIST_UNSUPPORTED);
18189 denied_ids.push(denied.client_order_id);
18190 }
18191 denied_ids.sort();
18192 assert_eq!(
18193 denied_ids,
18194 [first.client_order_id(), second.client_order_id()]
18195 );
18196 assert!(state.recorded_requests().is_empty());
18197 assert!(client.in_flight.lock().is_none());
18198
18199 for order in &orders {
18200 let cache_ref = cache.borrow();
18201 let cached = cache_ref.order(&order.client_order_id()).unwrap();
18202 assert_eq!(cached.status(), OrderStatus::Initialized);
18203 }
18204 }
18205
18206 #[tokio::test]
18207 async fn modify_order_rejects_without_side_effects() {
18208 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18209 let order = test_market_sell_order(test_pool().instrument_id);
18210 let mut receiver = start_with_events(&mut client);
18211
18212 client
18213 .modify_order(modify_order_cmd(
18214 order.instrument_id(),
18215 order.client_order_id(),
18216 ))
18217 .unwrap();
18218
18219 let events = collect_order_events(&mut receiver);
18220 assert_eq!(events.len(), 1, "was: {events:?}");
18221 let OrderEventAny::ModifyRejected(rejected) = &events[0] else {
18222 panic!("expected OrderModifyRejected, was {:?}", events[0]);
18223 };
18224 assert_eq!(rejected.client_order_id, order.client_order_id());
18225 assert_eq!(rejected.reason.as_str(), ORDER_MODIFY_UNSUPPORTED);
18226 assert!(state.recorded_requests().is_empty());
18227 assert!(client.in_flight.lock().is_none());
18228 let cache_ref = cache.borrow();
18229 let cached = cache_ref.order(&order.client_order_id()).unwrap();
18230 assert_eq!(cached.status(), OrderStatus::Initialized);
18231 }
18232
18233 #[tokio::test]
18234 async fn cancel_order_rejects_without_side_effects() {
18235 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18236 let order = test_market_sell_order(test_pool().instrument_id);
18237 let mut receiver = start_with_events(&mut client);
18238
18239 client
18240 .cancel_order(cancel_order_cmd(
18241 order.instrument_id(),
18242 order.client_order_id(),
18243 ))
18244 .unwrap();
18245
18246 let events = collect_order_events(&mut receiver);
18247 assert_eq!(events.len(), 1, "was: {events:?}");
18248 let OrderEventAny::CancelRejected(rejected) = &events[0] else {
18249 panic!("expected OrderCancelRejected, was {:?}", events[0]);
18250 };
18251 assert_eq!(rejected.client_order_id, order.client_order_id());
18252 assert_eq!(rejected.reason.as_str(), ORDER_CANCEL_UNSUPPORTED);
18253 assert!(state.recorded_requests().is_empty());
18254 assert!(client.in_flight.lock().is_none());
18255 let cache_ref = cache.borrow();
18256 let cached = cache_ref.order(&order.client_order_id()).unwrap();
18257 assert_eq!(cached.status(), OrderStatus::Initialized);
18258 }
18259
18260 #[tokio::test]
18261 async fn batch_cancel_orders_rejects_each_order_without_side_effects() {
18262 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18263 let pool = test_pool();
18264 let first = test_market_sell_order(pool.instrument_id);
18265 let second = market_sell_order_with_id(pool.instrument_id, "O-SWAP-002");
18266 cache
18267 .borrow_mut()
18268 .add_order(second.clone(), None, None, false)
18269 .unwrap();
18270 let orders = [first.clone(), second.clone()];
18271 let cancels = orders
18272 .iter()
18273 .map(|order| cancel_order_cmd(order.instrument_id(), order.client_order_id()))
18274 .collect();
18275 let mut receiver = start_with_events(&mut client);
18276
18277 client
18278 .batch_cancel_orders(batch_cancel_orders_cmd(cancels))
18279 .unwrap();
18280
18281 let events = collect_order_events(&mut receiver);
18282 let mut rejected_ids = Vec::new();
18283
18284 for event in &events {
18285 let OrderEventAny::CancelRejected(rejected) = event else {
18286 panic!("expected OrderCancelRejected, was {event:?}");
18287 };
18288 assert_eq!(rejected.reason.as_str(), ORDER_CANCEL_UNSUPPORTED);
18289 rejected_ids.push(rejected.client_order_id);
18290 }
18291 rejected_ids.sort();
18292 assert_eq!(
18293 rejected_ids,
18294 [first.client_order_id(), second.client_order_id()]
18295 );
18296 assert!(state.recorded_requests().is_empty());
18297 assert!(client.in_flight.lock().is_none());
18298 }
18299
18300 #[tokio::test]
18301 async fn cancel_all_and_query_order_log_unsupported_without_side_effects() {
18302 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18303 let order = test_market_sell_order(test_pool().instrument_id);
18304 let mut receiver = start_with_events(&mut client);
18305
18306 client
18307 .cancel_all_orders(cancel_all_orders_cmd(order.instrument_id()))
18308 .unwrap();
18309 client
18310 .query_order(query_order_cmd(
18311 order.instrument_id(),
18312 order.client_order_id(),
18313 ))
18314 .unwrap();
18315
18316 let events = collect_order_events(&mut receiver);
18317 assert!(events.is_empty(), "was: {events:?}");
18318 assert!(state.recorded_requests().is_empty());
18319 assert!(client.in_flight.lock().is_none());
18320 let cache_ref = cache.borrow();
18321 let cached = cache_ref.order(&order.client_order_id()).unwrap();
18322 assert_eq!(cached.status(), OrderStatus::Initialized);
18323 }
18324
18325 #[tokio::test]
18326 async fn unsupported_commands_handle_unknown_orders_without_panic() {
18327 let (mut client, state, _) = unsupported_client_with_mock_rpc().await;
18328 let instrument_id = test_pool().instrument_id;
18329 let unknown = ClientOrderId::from("O-UNKNOWN");
18330 let mut receiver = start_with_events(&mut client);
18331
18332 client
18333 .modify_order(modify_order_cmd(instrument_id, unknown))
18334 .unwrap();
18335 client
18336 .cancel_order(cancel_order_cmd(instrument_id, unknown))
18337 .unwrap();
18338 client
18339 .batch_cancel_orders(batch_cancel_orders_cmd(vec![cancel_order_cmd(
18340 instrument_id,
18341 unknown,
18342 )]))
18343 .unwrap();
18344 client
18345 .query_order(query_order_cmd(instrument_id, unknown))
18346 .unwrap();
18347
18348 let events = collect_order_events(&mut receiver);
18349 assert!(events.is_empty(), "was: {events:?}");
18350 assert!(state.recorded_requests().is_empty());
18351 assert!(client.in_flight.lock().is_none());
18352 }
18353
18354 #[tokio::test]
18355 async fn report_generators_error_except_mass_status_without_side_effects() {
18356 let (client, state, _) = unsupported_client_with_mock_rpc().await;
18357
18358 let report = client
18359 .generate_order_status_report(&GenerateOrderStatusReport::new(
18360 UUID4::new(),
18361 UnixNanos::default(),
18362 None,
18363 None,
18364 None,
18365 None,
18366 None,
18367 ))
18368 .await
18369 .unwrap_err();
18370 assert_eq!(report.to_string(), VENUE_EXECUTION_REPORTS_UNSUPPORTED);
18371
18372 let reports = client
18373 .generate_order_status_reports(&GenerateOrderStatusReports::new(
18374 UUID4::new(),
18375 UnixNanos::default(),
18376 false,
18377 None,
18378 None,
18379 None,
18380 None,
18381 None,
18382 ))
18383 .await
18384 .unwrap_err();
18385 assert_eq!(reports.to_string(), VENUE_EXECUTION_REPORTS_UNSUPPORTED);
18386
18387 let fills = client
18388 .generate_fill_reports(GenerateFillReports::new(
18389 UUID4::new(),
18390 UnixNanos::default(),
18391 None,
18392 None,
18393 None,
18394 None,
18395 None,
18396 None,
18397 ))
18398 .await
18399 .unwrap_err();
18400 assert_eq!(fills.to_string(), VENUE_EXECUTION_REPORTS_UNSUPPORTED);
18401
18402 let positions = client
18403 .generate_position_status_reports(&GeneratePositionStatusReports::new(
18404 UUID4::new(),
18405 UnixNanos::default(),
18406 None,
18407 None,
18408 None,
18409 None,
18410 None,
18411 ))
18412 .await
18413 .unwrap_err();
18414 assert_eq!(positions.to_string(), VENUE_EXECUTION_REPORTS_UNSUPPORTED);
18415
18416 let mass_status = client.generate_mass_status(None).await.unwrap();
18417 assert!(mass_status.is_none());
18418
18419 let mass_status = client.generate_mass_status(Some(60)).await.unwrap();
18420 assert!(mass_status.is_none());
18421
18422 assert!(state.recorded_requests().is_empty());
18423 assert!(client.in_flight.lock().is_none());
18424 }
18425
18426 async fn connect_test_postgres(
18427 test_name: &str,
18428 ) -> Option<(sqlx::PgPool, PostgresConnectOptions)> {
18429 let pg_config = get_postgres_connect_options(None, None, None, None, None);
18430 let admin_options: sqlx::postgres::PgConnectOptions = pg_config.clone().into();
18431 let admin_pool = match PgPoolOptions::new()
18432 .max_connections(1)
18433 .connect_with(admin_options)
18434 .await
18435 {
18436 Ok(pool) => pool,
18437 Err(e) => {
18438 eprintln!("Postgres unavailable; skipping {test_name} test: {e}");
18439 return None;
18440 }
18441 };
18442
18443 Some((admin_pool, pg_config))
18444 }
18445
18446 async fn execution_client_with_database(
18447 test_name: &str,
18448 state: MockRpcState,
18449 ) -> Option<(
18450 sqlx::PgPool,
18451 String,
18452 BlockchainExecutionClient,
18453 MockRpcState,
18454 )> {
18455 let (admin_pool, schema, mut client, state) =
18456 execution_client_with_unprotected_database(test_name, state).await?;
18457 protect_test_storage(&mut client, &schema).await;
18458 Some((admin_pool, schema, client, state))
18459 }
18460
18461 async fn execution_client_with_unprotected_database(
18462 test_name: &str,
18463 state: MockRpcState,
18464 ) -> Option<(
18465 sqlx::PgPool,
18466 String,
18467 BlockchainExecutionClient,
18468 MockRpcState,
18469 )> {
18470 let (admin_pool, pg_config) = connect_test_postgres(test_name).await?;
18471 let schema = format!("{test_name}_{}", std::process::id());
18472 setup_execution_schema(&admin_pool, &schema).await;
18473
18474 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
18475 let db_options = db_options.options([("search_path", schema.clone())]);
18476 let database = connect_test_database(db_options).await.unwrap();
18477 let addr = start_mock_rpc_server(state.clone()).await;
18478 let mut client = test_client(format!("http://{addr}"));
18479 client.cache.database = Some(database);
18480 client
18482 .cache
18483 .ensure_execution_transaction_schema()
18484 .await
18485 .unwrap();
18486 initialize_test_verification_ledger(&client).await;
18487 client.signer = Some(Arc::new(
18488 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
18489 ));
18490 client.core.set_connected();
18491
18492 Some((admin_pool, schema, client, state))
18493 }
18494
18495 async fn install_reservation_commit_rejection(admin_pool: &sqlx::PgPool, schema: &str) {
18496 for statement in [
18497 format!(
18498 "CREATE FUNCTION {schema}.reject_execution_reservation_commit() RETURNS trigger \
18499 LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''test reservation commit rejection''; \
18500 RETURN NEW; END'"
18501 ),
18502 format!(
18503 "CREATE CONSTRAINT TRIGGER reject_execution_reservation_commit AFTER INSERT ON \
18504 {schema}.execution_transaction_transition DEFERRABLE INITIALLY DEFERRED \
18505 FOR EACH ROW EXECUTE FUNCTION {schema}.reject_execution_reservation_commit()"
18506 ),
18507 ] {
18508 sqlx::query(sqlx::AssertSqlSafe(statement))
18509 .execute(admin_pool)
18510 .await
18511 .unwrap();
18512 }
18513 }
18514
18515 async fn install_recoverable_commit_rejection(admin_pool: &sqlx::PgPool, schema: &str) {
18516 for statement in [
18517 format!(
18518 "CREATE FUNCTION {schema}.reject_recoverable_commit() RETURNS trigger \
18519 LANGUAGE plpgsql AS 'BEGIN IF NEW.transition_key = ''recoverable'' THEN \
18520 RAISE EXCEPTION ''test recoverable commit rejection''; END IF; \
18521 RETURN NEW; END'"
18522 ),
18523 format!(
18524 "CREATE CONSTRAINT TRIGGER reject_recoverable_commit AFTER INSERT ON \
18525 {schema}.execution_transaction_transition DEFERRABLE INITIALLY DEFERRED \
18526 FOR EACH ROW EXECUTE FUNCTION {schema}.reject_recoverable_commit()"
18527 ),
18528 ] {
18529 sqlx::query(sqlx::AssertSqlSafe(statement))
18530 .execute(admin_pool)
18531 .await
18532 .unwrap();
18533 }
18534 }
18535
18536 async fn drop_execution_schema(admin_pool: &sqlx::PgPool, schema: &str) {
18537 sqlx::query(sqlx::AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE")))
18538 .execute(admin_pool)
18539 .await
18540 .unwrap();
18541 }
18542
18543 async fn setup_execution_schema(admin_pool: &sqlx::PgPool, schema: &str) {
18544 for statement in [
18545 format!("CREATE SCHEMA {schema}"),
18546 format!(
18547 r#"CREATE TABLE {schema}."chain" (chain_id INTEGER PRIMARY KEY, name TEXT NOT NULL)"#
18548 ),
18549 format!(r#"INSERT INTO {schema}."chain" (chain_id, name) VALUES (42161, 'Arbitrum')"#),
18550 format!(
18551 r#"CREATE TABLE {schema}."execution_transaction" (
18552 id BIGSERIAL PRIMARY KEY,
18553 chain_id INTEGER NOT NULL REFERENCES {schema}."chain"(chain_id) ON DELETE CASCADE,
18554 nonce BIGINT NOT NULL,
18555 transaction_hash TEXT NOT NULL,
18556 purpose TEXT NOT NULL,
18557 status TEXT NOT NULL,
18558 UNIQUE (chain_id, transaction_hash)
18559 )"#
18560 ),
18561 ] {
18562 sqlx::query(sqlx::AssertSqlSafe(statement))
18563 .execute(admin_pool)
18564 .await
18565 .unwrap();
18566 }
18567 }
18568
18569 fn execution_transaction_create_sql() -> &'static str {
18570 const TABLES_SQL: &str = include_str!("../../../../../schema/sql/tables.sql");
18571 const START: &str = "CREATE TABLE IF NOT EXISTS \"execution_transaction\"";
18572 let start = TABLES_SQL
18573 .find(START)
18574 .expect("execution_transaction table is missing from tables.sql");
18575 let statement = &TABLES_SQL[start..];
18576 let end = statement
18577 .find(";\n")
18578 .expect("execution_transaction CREATE TABLE is unterminated")
18579 + 1;
18580 &statement[..end]
18581 }
18582}