1use std::{
17 collections::{HashMap, HashSet},
18 fmt::Debug,
19 ops::RangeInclusive,
20 str::FromStr,
21 sync::Arc,
22 time::{Duration, SystemTime, UNIX_EPOCH},
23};
24
25use alloy::{
26 primitives::{
27 Address, B256, Bytes, I256, U256,
28 aliases::{U24, U160},
29 keccak256,
30 },
31 signers::local::PrivateKeySigner,
32 sol_types::SolCall,
33};
34use anyhow::Context;
35use async_trait::async_trait;
36use nautilus_common::{
37 clients::ExecutionClient,
38 live::runner::get_exec_event_sender,
39 messages::execution::{
40 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
41 GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
42 ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
43 },
44};
45use nautilus_core::{
46 Params, UUID4, UnixNanos, datetime::NANOSECONDS_IN_SECOND, hex, time::get_atomic_clock_realtime,
47};
48use nautilus_live::{
49 ExecutionClientCore, ExecutionEventEmitter,
50 task::{TaskGroup, TaskGroupGuard},
51};
52use nautilus_model::{
53 accounts::AccountAny,
54 defi::{
55 DexType, Pool, PoolIdentifier, SharedChain, Token,
56 data::block::{BLOCK_SCOPED_SNAPSHOT_INDEX, BlockPosition},
57 pool_analysis::quote::SwapQuote,
58 validation::validate_address,
59 wallet::{TokenBalance, WalletBalance},
60 },
61 enums::{CurrencyType, LiquiditySide, OmsType, OrderSide, OrderStatus, OrderType},
62 events::{OrderCanceled, OrderDeniedReason, OrderEventAny, OrderFilled, OrderRejected},
63 identifiers::{AccountId, ClientId, ClientOrderId, InstrumentId, TradeId, Venue, VenueOrderId},
64 orders::{Order, OrderAny},
65 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
66 types::{
67 AccountBalance, Currency, MarginBalance, Money, Price, Quantity, fixed::FIXED_PRECISION,
68 },
69};
70use parking_lot::Mutex;
71use zeroize::Zeroizing;
72
73use crate::{
74 cache::{
75 BlockchainCache,
76 database::{
77 BlockchainCacheDatabase, ExecutionFinalityTransition, ExecutionNonceAssignment,
78 ExecutionPayloadCheck, ExecutionPayloadLease, ExecutionReplacementScan,
79 ExecutionVerificationBatch, ExecutionVerificationBootstrap,
80 ExecutionVerificationDecision, ExecutionVerificationMigration,
81 ExecutionVerificationMigrationRecord, ExecutionVerificationMigrationSnapshot,
82 ExecutionVerifiedHeader, reservation_failure_proven_not_committed,
83 },
84 rows::{ExecutionIntentInsert, ExecutionIntentRow, ExecutionTransactionHashRow},
85 },
86 config::{
87 BlockchainContractRole, BlockchainDeploymentManifest, BlockchainExecutionClientConfig,
88 },
89 contracts::{
90 erc20::{ERC20, Erc20Contract},
91 uniswap_v3_quote::UniswapV3Quote,
92 uniswap_v3_swap::{UniswapV3Factory, UniswapV3RouterState, UniswapV3SwapRouter},
93 weth::WETH9,
94 },
95 execution::{
96 preflight::{
97 BlockchainPreflightReport, ContractCodeCheck, PoolPreflightCheck, TokenPreflightCheck,
98 },
99 sealing::{
100 PayloadKeySet, PayloadPolicy, authenticate_payload, authenticate_payload_identity,
101 authenticate_retained_payload, payload_context, payload_context_identity,
102 persisted_call_fields, retained_payload_requires_policy,
103 },
104 transaction::{
105 TransactionPurpose, TransactionStatus, build_eip1559_transaction, compute_max_fee,
106 decode_signed_transaction, derive_fees, derive_gas_limit, sign_eip1559_transaction,
107 },
108 },
109 rpc::{
110 error::BroadcastError,
111 http::{BlockchainHttpRpcClient, EXECUTION_RPC_TIMEOUT_SECS},
112 log as rpc_log,
113 types::{RpcCallType, RpcTransaction, RpcTransactionReceipt},
114 verification::{
115 VerificationCoordinator, VerificationOutcome, Verified, VerifiedBlockHeader,
116 VerifiedCallTrace, VerifiedSimulation,
117 },
118 },
119};
120
121const RECEIPT_POLL_INTERVAL: Duration = Duration::from_secs(1);
122const MAX_PAYLOAD_OPERATION_BATCH_SIZE: usize = 1_000;
123const BPS_DENOMINATOR: u32 = 10_000;
124const ORDER_LIST_UNSUPPORTED: &str =
126 "Order lists are not supported; submit each order individually";
127const ORDER_MODIFY_UNSUPPORTED: &str = "Order modification is not supported";
129const ORDER_CANCEL_UNSUPPORTED: &str = "Order cancellation is not supported";
131
132const VENUE_EXECUTION_REPORTS_UNSUPPORTED: &str =
134 "Venue execution reports are not supported on the blockchain execution client";
135const MAX_REPLACEMENT_SCAN_BLOCKS: u64 = 4_096;
136
137#[derive(Debug)]
139pub struct BlockchainExecutionClient {
140 core: ExecutionClientCore,
141 emitter: ExecutionEventEmitter,
142 cache: BlockchainCache,
143 config: BlockchainExecutionClientConfig,
144 chain: SharedChain,
145 wallet_address: Address,
146 signer: Option<Arc<PrivateKeySigner>>,
147 payload_keys: Option<Arc<PayloadKeySet>>,
148 router_addresses: Vec<Address>,
149 transaction_limits: TransactionLimits,
150 weth_address: Address,
151 in_flight: Arc<Mutex<Option<InFlightSlot>>>,
152 wallet_balance: Arc<Mutex<WalletBalance>>,
153 erc20_contract: Erc20Contract,
154 http_rpc_client: Arc<BlockchainHttpRpcClient>,
155 verification: VerificationCoordinator,
156 pending_tasks: TaskGroup,
157}
158
159impl BlockchainExecutionClient {
160 pub fn new(
169 core_client: ExecutionClientCore,
170 config: BlockchainExecutionClientConfig,
171 ) -> anyhow::Result<Self> {
172 let transaction_limits = Self::transaction_limits(&config)?;
173 let chain = Arc::new(config.chain.clone());
174 let cache = BlockchainCache::new(chain.clone());
175 let http_rpc_client = Arc::new(BlockchainHttpRpcClient::new(
176 config.http_rpc_url.clone().into_inner(),
177 config.rpc_requests_per_second,
178 None,
179 ));
180 let verification_config = config.verification.as_ref().ok_or_else(|| {
181 anyhow::anyhow!("Independent Blockchain execution verification is required")
182 })?;
183 anyhow::ensure!(
184 verification_config.chain_anchor.chain_id == config.chain.chain_id,
185 "Verification chain anchor ID does not match the configured chain"
186 );
187 anyhow::ensure!(
188 verification_config.chain_anchor.chain_name == config.chain.name.to_string(),
189 "Verification chain anchor name does not match the configured chain"
190 );
191 let verification = VerificationCoordinator::new(
192 http_rpc_client.clone(),
193 config.http_rpc_url.expose_secret(),
194 verification_config,
195 config.rpc_requests_per_second,
196 )?;
197 let wallet_address = validate_address(config.wallet_address.as_str())?;
198 let erc20_contract = Erc20Contract::new_with_timeout(
199 http_rpc_client.clone(),
200 Some(EXECUTION_RPC_TIMEOUT_SECS),
201 true,
202 );
203
204 let router_addresses = config
205 .router_addresses
206 .iter()
207 .map(|address| validate_address(address.as_str()))
208 .collect::<anyhow::Result<Vec<_>>>()?;
209 if router_addresses.is_empty() {
210 anyhow::bail!("`router_addresses` must contain at least one router address");
211 }
212 let weth_address = validate_address(config.weth_address.as_str())?;
213 Self::validate_manifest_contracts(&config, &router_addresses, weth_address)?;
214
215 let mut token_universe = HashSet::new();
216
217 if let Some(specified_tokens) = &config.tokens {
218 for token in specified_tokens {
219 let token_address = validate_address(token.as_str())?;
220 token_universe.insert(token_address);
221 }
222 }
223 let wallet_balance = WalletBalance::new(token_universe);
224 let emitter = ExecutionEventEmitter::new(
225 get_atomic_clock_realtime(),
226 core_client.trader_id,
227 core_client.account_id,
228 core_client.account_type,
229 core_client.base_currency,
230 );
231
232 let pending_tasks = TaskGroup::new();
233
234 Ok(Self {
235 core: core_client,
236 emitter,
237 wallet_balance: Arc::new(Mutex::new(wallet_balance)),
238 chain,
239 cache,
240 config,
241 signer: None,
242 payload_keys: None,
243 router_addresses,
244 transaction_limits,
245 weth_address,
246 in_flight: Arc::new(Mutex::new(None)),
247 erc20_contract,
248 http_rpc_client,
249 verification,
250 wallet_address,
251 pending_tasks,
252 })
253 }
254
255 fn transaction_limits(
256 config: &BlockchainExecutionClientConfig,
257 ) -> anyhow::Result<TransactionLimits> {
258 let (
259 Some(allowed_token_pairs),
260 Some(slippage_bps),
261 Some(max_slippage_bps),
262 Some(max_order_amount),
263 Some(deadline_seconds),
264 Some(max_quote_age_blocks),
265 Some(receipt_timeout_secs),
266 ) = (
267 &config.allowed_token_pairs,
268 config.slippage_bps,
269 config.max_slippage_bps,
270 config.max_order_amount,
271 config.deadline_seconds,
272 config.max_quote_age_blocks,
273 config.receipt_timeout_secs,
274 )
275 else {
276 anyhow::bail!(
277 "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"
278 );
279 };
280
281 let mut parsed_pairs = HashSet::with_capacity(allowed_token_pairs.len());
282 for (token_in, token_out) in allowed_token_pairs {
283 parsed_pairs.insert((
284 validate_address(token_in.as_str())?,
285 validate_address(token_out.as_str())?,
286 ));
287 }
288
289 let quote_spend_limits = config.quote_spend_limits.as_deref().unwrap_or_default();
290 let mut parsed_quote_spend_limits = HashMap::with_capacity(quote_spend_limits.len());
291 for limit in quote_spend_limits {
292 let token_in = validate_address(limit.token_in.as_str())?;
293 let token_out = validate_address(limit.token_out.as_str())?;
294 let spend_token = validate_address(limit.spend_token.as_str())?;
295
296 if !parsed_pairs.contains(&(token_in, token_out)) {
297 anyhow::bail!(
298 "Quote spend limit pair {token_in} -> {token_out} is not in the `allowed_token_pairs` allowlist"
299 );
300 }
301
302 if spend_token != token_in {
303 anyhow::bail!(
304 "Quote spend limit for {token_in} -> {token_out} is denominated in {spend_token}; `spend_token` must match `token_in`"
305 );
306 }
307
308 if limit.max_amount.is_empty()
309 || !limit.max_amount.bytes().all(|byte| byte.is_ascii_digit())
310 {
311 anyhow::bail!(
312 "Quote spend limit `max_amount` '{}' must be a base-10 unsigned integer string",
313 limit.max_amount
314 );
315 }
316 let max_amount = U256::from_str(&limit.max_amount).map_err(|_| {
317 anyhow::anyhow!(
318 "Quote spend limit `max_amount` '{}' exceeds the U256 range",
319 limit.max_amount
320 )
321 })?;
322 let ceiling = QuoteSpendCeiling {
323 spend_token,
324 spend_token_decimals: limit.spend_token_decimals,
325 max_amount,
326 };
327
328 if parsed_quote_spend_limits
329 .insert((token_in, token_out), ceiling)
330 .is_some()
331 {
332 anyhow::bail!(
333 "Duplicate quote spend limit for token pair {token_in} -> {token_out}"
334 );
335 }
336 }
337
338 if slippage_bps > max_slippage_bps {
339 anyhow::bail!(
340 "`slippage_bps` {slippage_bps} exceeds `max_slippage_bps` {max_slippage_bps}"
341 );
342 }
343
344 if max_slippage_bps >= BPS_DENOMINATOR {
345 anyhow::bail!("`max_slippage_bps` {max_slippage_bps} must be below {BPS_DENOMINATOR}");
346 }
347
348 if !(1..=4_095).contains(&max_quote_age_blocks) {
349 anyhow::bail!("`max_quote_age_blocks` must be in 1..=4095");
350 }
351
352 Ok(TransactionLimits {
353 allowed_token_pairs: parsed_pairs,
354 quote_spend_limits: parsed_quote_spend_limits,
355 slippage_bps,
356 max_slippage_bps,
357 max_order_amount,
358 deadline_seconds,
359 max_quote_age_blocks,
360 receipt_timeout_secs,
361 })
362 }
363
364 fn validate_manifest_contracts(
365 config: &BlockchainExecutionClientConfig,
366 routers: &[Address],
367 weth: Address,
368 ) -> anyhow::Result<()> {
369 let verification = config.verification.as_ref().ok_or_else(|| {
370 anyhow::anyhow!("Independent Blockchain execution verification is required")
371 })?;
372 let manifest = &verification.deployment_manifest;
373 let role_addresses = |role| {
374 manifest
375 .contracts
376 .iter()
377 .filter(|contract| contract.role == role)
378 .map(|contract| {
379 Address::from_str(&contract.address)
380 .map_err(|_| anyhow::anyhow!("Deployment manifest address is invalid"))
381 })
382 .collect::<anyhow::Result<HashSet<_>>>()
383 };
384 let singleton = |role, description: &str| {
385 let addresses = role_addresses(role)?;
386 anyhow::ensure!(
387 addresses.len() == 1,
388 "Deployment manifest must contain exactly one {description} contract"
389 );
390 Ok(*addresses.iter().next().expect("singleton role address"))
391 };
392
393 let configured_routers = routers.iter().copied().collect::<HashSet<_>>();
394 anyhow::ensure!(
395 role_addresses(BlockchainContractRole::Router)? == configured_routers,
396 "Deployment manifest router set does not match `router_addresses`"
397 );
398 anyhow::ensure!(
399 singleton(BlockchainContractRole::WrappedNative, "wrapped native")? == weth,
400 "Deployment manifest wrapped native contract does not match `weth_address`"
401 );
402 let factory = singleton(BlockchainContractRole::Factory, "factory")?;
403 let registered_factory =
404 crate::exchanges::get_dex_extended(config.chain.name, &DexType::UniswapV3)
405 .map(|dex| dex.factory)
406 .ok_or_else(|| {
407 anyhow::anyhow!(
408 "No registered Uniswap V3 deployment for chain {}",
409 config.chain.name
410 )
411 })?;
412 anyhow::ensure!(
413 factory == registered_factory,
414 "Deployment manifest factory does not match the registered Uniswap V3 factory"
415 );
416 let quote_contract = singleton(BlockchainContractRole::Quote, "quote")?;
417
418 let mut token_decimals = HashMap::new();
419
420 for token in &manifest.tokens {
421 let address = Address::from_str(&token.address)
422 .map_err(|_| anyhow::anyhow!("Deployment manifest token address is invalid"))?;
423 anyhow::ensure!(
424 token_decimals.insert(address, token.decimals).is_none(),
425 "Deployment manifest contains a duplicate token identity"
426 );
427 }
428 anyhow::ensure!(
429 token_decimals.contains_key(&weth),
430 "Deployment manifest has no wrapped native token identity"
431 );
432
433 if let Some(tokens) = &config.tokens {
434 for token in tokens {
435 let address = validate_address(token)?;
436 anyhow::ensure!(
437 token_decimals.contains_key(&address),
438 "Configured token {address} has no deployment manifest identity"
439 );
440 }
441 }
442
443 for (token_in, token_out) in config.allowed_token_pairs.as_deref().unwrap_or_default() {
444 let token_in = validate_address(token_in)?;
445 let token_out = validate_address(token_out)?;
446 anyhow::ensure!(
447 token_in != token_out
448 && token_decimals.contains_key(&token_in)
449 && token_decimals.contains_key(&token_out),
450 "Allowed token pair {token_in} -> {token_out} is not fully pinned by the deployment manifest"
451 );
452 }
453
454 for limit in config.quote_spend_limits.as_deref().unwrap_or_default() {
455 let spend_token = validate_address(&limit.spend_token)?;
456 anyhow::ensure!(
457 token_decimals.get(&spend_token) == Some(&limit.spend_token_decimals),
458 "Quote spend limit decimals do not match the deployment manifest"
459 );
460 }
461
462 let pool_contracts = role_addresses(BlockchainContractRole::Pool)?;
463
464 for pool in &manifest.pools {
465 let pool_address = Address::from_str(&pool.address)
466 .map_err(|_| anyhow::anyhow!("Deployment manifest pool address is invalid"))?;
467 let pool_factory = Address::from_str(&pool.factory)
468 .map_err(|_| anyhow::anyhow!("Deployment manifest pool factory is invalid"))?;
469 let pool_quote = Address::from_str(&pool.quote_contract).map_err(|_| {
470 anyhow::anyhow!("Deployment manifest pool quote contract is invalid")
471 })?;
472 anyhow::ensure!(
473 pool_contracts.contains(&pool_address)
474 && pool_factory == factory
475 && pool_quote == quote_contract,
476 "Deployment manifest pool does not use the pinned pool, factory, and quote identities"
477 );
478 }
479 Ok(())
480 }
481
482 async fn fetch_native_currency_balance(&self) -> anyhow::Result<Money> {
483 let balance_u256 = self
484 .http_rpc_client
485 .get_balance_with_timeout(&self.wallet_address, None, Some(EXECUTION_RPC_TIMEOUT_SECS))
486 .await?;
487
488 let native_currency = self.chain.native_currency();
489
490 Money::from_u256(balance_u256, native_currency).map_err(Into::into)
491 }
492
493 async fn fetch_token_balance(
494 &mut self,
495 token_address: &Address,
496 ) -> anyhow::Result<TokenBalance> {
497 let token = if let Some(token) = self.cache.get_token(token_address) {
498 token.to_owned()
499 } else {
500 let token_info = self.erc20_contract.fetch_token_info(token_address).await?;
501 let token = Token::new(
502 self.chain.clone(),
503 *token_address,
504 token_info.name,
505 token_info.symbol,
506 token_info.decimals,
507 );
508 self.cache.add_token(token.clone()).await?;
509 token
510 };
511
512 let amount = self
513 .erc20_contract
514 .balance_of(token_address, &self.wallet_address)
515 .await?;
516 let token_balance = TokenBalance::new(amount, token);
517
518 Ok(token_balance)
522 }
523
524 async fn refresh_wallet_balances(&mut self) -> anyhow::Result<()> {
526 let (wallet_balance, balances) = self.fetch_wallet_balances().await?;
527 self.generate_account_state(
528 balances,
529 vec![],
530 true,
531 get_atomic_clock_realtime().get_time_ns(),
532 None,
533 )?;
534 *self.wallet_balance.lock() = wallet_balance;
535 Ok(())
536 }
537
538 async fn fetch_wallet_balances(
539 &mut self,
540 ) -> anyhow::Result<(WalletBalance, Vec<AccountBalance>)> {
541 let native_currency_balance = self.fetch_native_currency_balance().await?;
542 let token_universe = self.wallet_balance.lock().token_universe.clone();
543 let mut token_addresses = token_universe.iter().copied().collect::<Vec<_>>();
544 token_addresses.sort_unstable();
545
546 let mut token_balances = Vec::with_capacity(token_addresses.len());
547 for token_address in token_addresses {
548 let token_balance = self
549 .fetch_token_balance(&token_address)
550 .await
551 .with_context(|| format!("failed to fetch token balance for {token_address}"))?;
552 token_balances.push(token_balance);
553 }
554
555 let mut wallet_balance = WalletBalance::new(token_universe);
556 let balances = wallet_balance.replace_balances(native_currency_balance, token_balances)?;
557 log::debug!(
558 "Refreshed wallet balance with {} account balances",
559 balances.len()
560 );
561 Ok((wallet_balance, balances))
562 }
563
564 pub async fn preflight(
576 &self,
577 instrument_id: &InstrumentId,
578 ) -> anyhow::Result<BlockchainPreflightReport> {
579 let pool = self.resolve_pool(instrument_id)?;
580 let base_token = pool.get_base_token().clone();
581 let quote_token = pool.get_quote_token().clone();
582
583 let actual_chain_id = self.http_rpc_client.chain_id().await?;
584
585 let pool_code = self.http_rpc_client.get_code(&pool.address).await?;
586 let pool_check = PoolPreflightCheck {
587 instrument_id: pool.instrument_id,
588 address: pool.address,
589 has_deployed_code: !pool_code.is_empty(),
590 fee: pool.fee,
591 base_token: base_token.address,
592 quote_token: quote_token.address,
593 };
594
595 let mut routers = Vec::with_capacity(self.router_addresses.len());
596 for router in &self.router_addresses {
597 let code = self.http_rpc_client.get_code(router).await?;
598 routers.push(ContractCodeCheck {
599 address: *router,
600 has_deployed_code: !code.is_empty(),
601 });
602 }
603
604 let mut tokens = Vec::with_capacity(2);
605
606 for token in [&base_token, "e_token] {
607 let code = self.http_rpc_client.get_code(&token.address).await?;
608 let wallet_balance = self
609 .erc20_contract
610 .balance_of(&token.address, &self.wallet_address)
611 .await?;
612
613 let mut router_allowances = Vec::new();
614 if token.address == base_token.address {
615 router_allowances.reserve(self.router_addresses.len());
616 for router in &self.router_addresses {
617 let amount = self
618 .erc20_contract
619 .allowance(&token.address, &self.wallet_address, router)
620 .await?;
621 router_allowances.push((*router, amount));
622 }
623 }
624
625 tokens.push(TokenPreflightCheck {
626 address: token.address,
627 symbol: token.symbol.clone(),
628 has_deployed_code: !code.is_empty(),
629 wallet_balance,
630 router_allowances,
631 });
632 }
633
634 let native_balance_wei = self
635 .http_rpc_client
636 .get_balance_with_timeout(&self.wallet_address, None, Some(EXECUTION_RPC_TIMEOUT_SECS))
637 .await?;
638
639 let latest_block = self.http_rpc_client.latest_block().await?;
640 let base_fee_per_gas_wei = latest_block.base_fee_per_gas.ok_or_else(|| {
641 anyhow::anyhow!("Latest block {} has no base fee", latest_block.number)
642 })?;
643 let max_priority_fee_per_gas_wei = self.http_rpc_client.max_priority_fee_per_gas().await?;
644 let derived_max_fee_per_gas_wei = compute_max_fee(
645 base_fee_per_gas_wei,
646 max_priority_fee_per_gas_wei,
647 self.config.base_fee_buffer_bps,
648 )?;
649
650 Ok(BlockchainPreflightReport::new(
651 u64::from(self.chain.chain_id),
652 actual_chain_id,
653 pool_check,
654 routers,
655 tokens,
656 native_balance_wei,
657 base_fee_per_gas_wei,
658 max_priority_fee_per_gas_wei,
659 derived_max_fee_per_gas_wei,
660 u128::from(self.config.max_fee_per_gas_wei),
661 ))
662 }
663
664 pub async fn wrap(&mut self, amount_wei: U256) -> anyhow::Result<B256> {
677 if amount_wei.is_zero() {
678 anyhow::bail!("Wrap amount must be positive");
679 }
680
681 self.ensure_transaction_ready(TransactionPurpose::Wrap)?;
682
683 let calldata = WETH9::depositCall {}.abi_encode();
684 let executor = self.transaction_executor()?;
685 let included = executor
686 .transact(
687 self.weth_address,
688 amount_wei,
689 Bytes::from(calldata),
690 TransactionPurpose::Wrap,
691 None,
692 TransactionAuthorization::Wrap {
693 weth: self.weth_address,
694 },
695 )
696 .await?;
697 let postconditions =
698 verify_wrap_balance_increase(&executor, &self.weth_address, amount_wei, &included)
699 .await?;
700 executor
701 .commit_verified_finality(&included, TransactionStatus::Finalized, &postconditions)
702 .await?;
703 executor
704 .database
705 .mark_execution_event_emitted(included.intent_id, "terminal")
706 .await?;
707 executor.release_slot();
708
709 Ok(included.tx_hash)
710 }
711
712 pub async fn approve(
727 &mut self,
728 token: Address,
729 amount: U256,
730 router: Address,
731 ) -> anyhow::Result<B256> {
732 if !self.router_addresses.contains(&router) {
733 anyhow::bail!("Router {router} is not in the configured `router_addresses` allowlist");
734 }
735
736 if !amount.is_zero()
737 && !self
738 .transaction_limits
739 .allowed_token_pairs
740 .iter()
741 .any(|(token_in, _)| *token_in == token)
742 {
743 anyhow::bail!(
744 "Token {token} is not an input token in the configured `allowed_token_pairs`"
745 );
746 }
747
748 self.ensure_transaction_ready(TransactionPurpose::Approve)?;
749
750 let approval_amount = if amount.is_zero() {
751 U256::ZERO
752 } else if self.config.unlimited_approval {
753 U256::MAX
754 } else {
755 amount
756 };
757 let calldata = ERC20::approveCall {
758 spender: router,
759 amount: approval_amount,
760 }
761 .abi_encode();
762
763 let executor = self.transaction_executor()?;
764 let included = executor
765 .transact(
766 token,
767 U256::ZERO,
768 Bytes::from(calldata),
769 TransactionPurpose::Approve,
770 None,
771 TransactionAuthorization::Approve {
772 token,
773 router,
774 amount: approval_amount,
775 },
776 )
777 .await?;
778 let postconditions =
779 verify_approve_allowance(&executor, &token, &router, approval_amount, &included)
780 .await?;
781 executor
782 .commit_verified_finality(&included, TransactionStatus::Finalized, &postconditions)
783 .await?;
784 executor
785 .database
786 .mark_execution_event_emitted(included.intent_id, "terminal")
787 .await?;
788 executor.release_slot();
789
790 Ok(included.tx_hash)
791 }
792
793 fn uniswap_v3_factory(&self) -> anyhow::Result<Address> {
794 crate::exchanges::get_dex_extended(self.chain.name, &DexType::UniswapV3)
795 .map(|dex| dex.factory)
796 .ok_or_else(|| {
797 anyhow::anyhow!(
798 "No registered Uniswap V3 deployment for chain {}",
799 self.chain.name
800 )
801 })
802 }
803
804 fn resolve_pool(&self, instrument_id: &InstrumentId) -> anyhow::Result<Pool> {
805 let (blockchain, dex_type) = instrument_id.venue.parse_dex()?;
806 if blockchain != self.chain.name {
807 anyhow::bail!(
808 "Pool venue chain {blockchain} does not match the client chain {}",
809 self.chain.name
810 );
811 }
812
813 if dex_type != DexType::UniswapV3 {
814 anyhow::bail!("Unsupported DEX type {dex_type}; only UniswapV3 is supported");
815 }
816
817 let pool_identifier = PoolIdentifier::new_checked(instrument_id.symbol.as_str())?;
818 if !pool_identifier.is_address() {
819 anyhow::bail!(
820 "Pool identifier {pool_identifier} is a pool ID; only address identifiers are supported"
821 );
822 }
823
824 let pool = self
825 .core
826 .cache()
827 .pool(instrument_id)
828 .cloned()
829 .ok_or_else(|| {
830 anyhow::anyhow!(
831 "Unknown pool {instrument_id}; not found in the shared engine cache"
832 )
833 })?;
834
835 if pool.token0.get_token_priority() == pool.token1.get_token_priority() {
836 anyhow::bail!(
837 "Pool {instrument_id} tokens share a token priority; base and quote orientation is ambiguous"
838 );
839 }
840
841 Ok(pool)
842 }
843
844 pub async fn check_payload_storage(
855 &self,
856 batch_size: usize,
857 ) -> anyhow::Result<PayloadStorageCheck> {
858 let batch_size = validate_payload_operation_batch_size(batch_size)?;
859 let database = self.payload_operation_database().await?;
860 let keys = self.load_payload_keys()?;
861 database
862 .check_execution_payload_storage(keys.as_ref(), None, batch_size)
863 .await
864 .map(Into::into)
865 }
866
867 pub async fn protect_payload_storage(&self) -> anyhow::Result<()> {
877 let database = self.payload_operation_database().await?;
878 database.ensure_execution_transaction_schema().await?;
879 let keys = self
880 .load_payload_keys()?
881 .ok_or_else(|| anyhow::anyhow!("Payload protection requires an active payload key"))?;
882 database.ensure_execution_payload_storage(&keys).await
883 }
884
885 pub async fn rewrap_payload_storage(&self, batch_size: usize) -> anyhow::Result<()> {
895 let batch_size = validate_payload_operation_batch_size(batch_size)?;
896 let database = self.payload_operation_database().await?;
897 let keys = self
898 .load_payload_keys()?
899 .ok_or_else(|| anyhow::anyhow!("Payload rewrap requires an active payload key"))?;
900 database
901 .rewrap_execution_payload_storage(&keys, batch_size)
902 .await
903 }
904
905 pub async fn rollback_payload_storage(&self, batch_size: usize) -> anyhow::Result<()> {
915 let batch_size = validate_payload_operation_batch_size(batch_size)?;
916 let database = self.payload_operation_database().await?;
917 let keys = self
918 .load_payload_keys()?
919 .ok_or_else(|| anyhow::anyhow!("Payload rollback requires an active payload key"))?;
920 database
921 .rollback_execution_payload_storage(&keys, batch_size)
922 .await
923 }
924
925 async fn payload_operation_database(&self) -> anyhow::Result<BlockchainCacheDatabase> {
926 anyhow::ensure!(
927 !self.core.is_connected(),
928 "Disconnect the execution client before payload storage operations"
929 );
930
931 if let Some(database) = &self.cache.database {
932 return Ok(database.clone());
933 }
934 let options = self
935 .config
936 .postgres_cache_database_config
937 .as_ref()
938 .ok_or_else(|| anyhow::anyhow!("No Postgres cache database is configured"))?;
939 BlockchainCacheDatabase::connect(options.clone().into())
940 .await
941 .context("failed to connect to the execution database")
942 }
943
944 fn load_payload_keys(&self) -> anyhow::Result<Option<PayloadKeySet>> {
945 PayloadKeySet::load(
946 self.config.payload_key_env.as_deref(),
947 &self.config.payload_key_retired_env,
948 self.config.payload_deployment_id.as_deref(),
949 )
950 }
951
952 fn payload_policy(&self) -> PayloadPolicy {
953 PayloadPolicy {
954 chain_id: self.chain.chain_id,
955 signer: self.wallet_address,
956 gas_limit: self.config.gas_limit,
957 max_fee_per_gas: self.config.max_fee_per_gas_wei,
958 }
959 }
960
961 fn transaction_executor(&self) -> anyhow::Result<TransactionExecutor> {
962 let database = self.cache.database.clone().ok_or_else(|| {
963 anyhow::anyhow!("No durable store configured; refusing to submit a transaction")
964 })?;
965 let signer = self
966 .signer
967 .clone()
968 .ok_or_else(|| anyhow::anyhow!("Signer not initialized; connect the client first"))?;
969 let payload_keys = self.payload_keys.clone().ok_or_else(|| {
970 anyhow::anyhow!("Protected payload keys are not initialized; connect the client first")
971 })?;
972 let verification_config = self
973 .config
974 .verification
975 .as_ref()
976 .expect("verification config validated at construction");
977 let identities = std::iter::once(&verification_config.authoritative)
978 .chain(
979 verification_config
980 .verifiers
981 .iter()
982 .map(|provider| &provider.identity),
983 )
984 .collect::<Vec<_>>();
985
986 Ok(TransactionExecutor {
987 http_rpc_client: self.http_rpc_client.clone(),
988 verification: self.verification.clone(),
989 manifest_version: verification_config.manifest_version.clone(),
990 manifest_digest: verification_config.manifest_digest.clone(),
991 deployment_manifest: Arc::new(verification_config.deployment_manifest.clone()),
992 provider_ids: identities
993 .iter()
994 .map(|identity| identity.provider_id.clone())
995 .collect(),
996 operator_ids: identities
997 .iter()
998 .map(|identity| identity.operator_id.clone())
999 .collect(),
1000 failure_domain_ids: identities
1001 .iter()
1002 .flat_map(|identity| identity.failure_domain_ids.iter().cloned())
1003 .collect(),
1004 database,
1005 signer,
1006 payload_keys,
1007 in_flight: Arc::clone(&self.in_flight),
1008 wallet_balance: Arc::clone(&self.wallet_balance),
1009 account_id: self.core.account_id,
1010 wallet_address: self.wallet_address,
1011 chain_id: self.chain.chain_id,
1012 max_fee_per_gas_wei: self.config.max_fee_per_gas_wei,
1013 base_fee_buffer_bps: self.config.base_fee_buffer_bps,
1014 gas_limit: self.config.gas_limit,
1015 gas_buffer_bps: self.config.gas_buffer_bps,
1016 receipt_timeout: receipt_timeout(self.transaction_limits.receipt_timeout_secs),
1017 receipt_max_polls: receipt_max_polls(self.transaction_limits.receipt_timeout_secs),
1018 })
1019 }
1020
1021 fn restore_swap_plan(&self, intent: &ExecutionIntentRow) -> anyhow::Result<SwapPlan> {
1022 let client_order_id = ClientOrderId::new_checked(
1023 intent
1024 .client_order_id
1025 .as_deref()
1026 .ok_or_else(|| anyhow::anyhow!("Persisted swap intent has no client order ID"))?,
1027 )?;
1028 let order = self
1029 .core
1030 .cache()
1031 .try_order_owned(&client_order_id)
1032 .with_context(|| {
1033 format!(
1034 "Cannot reconcile swap intent {} because order {client_order_id} is not restored",
1035 intent.id
1036 )
1037 })?;
1038 let instrument_id = InstrumentId::from_str(
1039 intent
1040 .instrument_id
1041 .as_deref()
1042 .ok_or_else(|| anyhow::anyhow!("Persisted swap intent has no instrument ID"))?,
1043 )?;
1044 anyhow::ensure!(
1045 order.instrument_id() == instrument_id,
1046 "Persisted swap instrument {instrument_id} does not match restored order instrument {}",
1047 order.instrument_id()
1048 );
1049 anyhow::ensure!(
1050 intent.trader_id.as_deref() == Some(order.trader_id().as_str()),
1051 "Persisted swap trader does not match restored order"
1052 );
1053 anyhow::ensure!(
1054 intent.strategy_id.as_deref() == Some(order.strategy_id().as_str()),
1055 "Persisted swap strategy does not match restored order"
1056 );
1057 anyhow::ensure!(
1058 intent.account_id.as_deref() == Some(self.core.account_id.as_str()),
1059 "Persisted swap account does not match execution client account"
1060 );
1061
1062 let pool = self.resolve_pool(&instrument_id)?;
1063 let pool_address = Address::from_str(
1064 intent
1065 .pool_address
1066 .as_deref()
1067 .ok_or_else(|| anyhow::anyhow!("Persisted swap intent has no pool address"))?,
1068 )?;
1069 anyhow::ensure!(
1070 pool.address == pool_address,
1071 "Persisted pool {pool_address} does not match restored pool {}",
1072 pool.address
1073 );
1074 let amount_in = U256::from_str(
1075 intent
1076 .amount_in
1077 .as_deref()
1078 .ok_or_else(|| anyhow::anyhow!("Persisted swap intent has no input amount"))?,
1079 )?;
1080 let fee = U24::try_from(
1081 pool.fee
1082 .ok_or_else(|| anyhow::anyhow!("Restored pool {instrument_id} has no fee"))?,
1083 )?;
1084 let quote_token = pool.get_quote_token();
1085 let quote_currency = Currency::new_checked(
1086 "e_token.symbol,
1087 quote_token.decimals,
1088 0,
1089 "e_token.name,
1090 CurrencyType::Crypto,
1091 )?;
1092 let (token_in, token_out) = swap_token_pair(
1093 order.order_side(),
1094 pool.get_base_token().address,
1095 quote_token.address,
1096 )?;
1097 let factory = self.uniswap_v3_factory()?;
1098 anyhow::ensure!(
1099 pool.dex.factory == factory,
1100 "Restored pool {instrument_id} references factory {}, expected registered factory {factory}",
1101 pool.dex.factory
1102 );
1103
1104 Ok(SwapPlan {
1105 order,
1106 quote_currency,
1107 pool,
1108 instrument_id,
1109 pool_address,
1110 router: Address::from_str(&intent.transaction_to)?,
1111 factory,
1112 weth: self.weth_address,
1113 token_in,
1114 token_out,
1115 fee,
1116 amount_in,
1117 min_amount_out: U256::ZERO,
1118 slippage_bps: 0,
1119 quote_spend_ceiling: None,
1120 profiler_position: None,
1121 })
1122 }
1123
1124 async fn reconcile_unresolved_execution(&self) -> anyhow::Result<()> {
1125 let database = self.cache.database.clone().ok_or_else(|| {
1126 anyhow::anyhow!("No durable store configured for execution reconciliation")
1127 })?;
1128 let _payload_lease = database
1129 .acquire_execution_payload_lease(self.payload_keys.as_deref().ok_or_else(|| {
1130 anyhow::anyhow!("Protected payload keys are required for execution recovery")
1131 })?)
1132 .await?;
1133 let wallet_address = self.wallet_address.to_string();
1134 anyhow::ensure!(
1135 !database
1136 .has_recoverable_signed_execution(self.chain.chain_id, &wallet_address)
1137 .await?,
1138 "A recoverable execution for wallet {} retains signed transaction bytes; refusing to reuse its nonce without explicit recovery",
1139 self.wallet_address
1140 );
1141 let Some(intent) = database
1142 .get_active_execution_intent(self.chain.chain_id, &wallet_address)
1143 .await?
1144 else {
1145 return Ok(());
1146 };
1147 anyhow::ensure!(
1148 intent.schema_version == crate::execution::transaction::EXECUTION_SCHEMA_VERSION,
1149 "Execution intent {} uses unsupported schema version {}",
1150 intent.id,
1151 intent.schema_version
1152 );
1153
1154 if intent.status == "prepared" {
1155 database
1156 .mark_execution_intent_recoverable(intent.id)
1157 .await?;
1158 release_preparing_slot(&self.in_flight);
1159 return Ok(());
1160 }
1161
1162 let purpose = TransactionPurpose::parse(&intent.purpose).ok_or_else(|| {
1163 anyhow::anyhow!(
1164 "Execution intent {} has unknown purpose {}",
1165 intent.id,
1166 intent.purpose
1167 )
1168 })?;
1169 let nonce = intent
1170 .nonce
1171 .ok_or_else(|| anyhow::anyhow!("Active execution intent {} has no nonce", intent.id))?;
1172 *self.in_flight.lock() = Some(InFlightSlot::Recovering(RecoveryTransaction {
1173 intent_id: intent.id,
1174 nonce,
1175 purpose,
1176 }));
1177 let hashes = database.get_execution_transaction_hashes(intent.id).await?;
1178 let current = current_execution_hash(intent.id, &hashes)?;
1179 let tx_hash = B256::from_str(¤t.transaction_hash).with_context(|| {
1180 format!(
1181 "Execution intent {} has invalid transaction hash {}",
1182 intent.id, current.transaction_hash
1183 )
1184 })?;
1185 let policy = PayloadPolicy {
1186 chain_id: self.chain.chain_id,
1187 signer: self.wallet_address,
1188 gas_limit: self.config.gas_limit,
1189 max_fee_per_gas: self.config.max_fee_per_gas_wei,
1190 };
1191 let mut authenticated_payloads = HashMap::new();
1192 let mut current_payload = None;
1193
1194 for hash in &hashes {
1195 anyhow::ensure!(
1196 hash.intent_id == intent.id,
1197 "Persisted transaction row references intent {}, expected {}",
1198 hash.intent_id,
1199 intent.id
1200 );
1201 anyhow::ensure!(
1202 hash.chain_id == self.chain.chain_id,
1203 "Persisted transaction row chain ID {} does not match configured chain ID {}",
1204 hash.chain_id,
1205 self.chain.chain_id
1206 );
1207
1208 if !hash.payload_expected {
1209 anyhow::ensure!(
1210 hash.raw_transaction.is_none() && hash.sealed_transaction.is_none(),
1211 "Replacement transaction {} unexpectedly retains signed bytes",
1212 hash.transaction_hash
1213 );
1214 continue;
1215 }
1216 let raw_transaction = open_execution_payload(
1217 self.payload_keys
1218 .as_deref()
1219 .expect("payload keys checked above"),
1220 policy,
1221 &intent,
1222 hash,
1223 "recovery",
1224 )
1225 .map_err(|e| {
1226 anyhow::anyhow!(
1227 "Execution intent {} signed transaction {} failed authentication: {e}",
1228 intent.id,
1229 hash.transaction_hash
1230 )
1231 })?;
1232 let authenticated_hash = B256::from_str(&hash.transaction_hash).with_context(|| {
1233 format!(
1234 "Execution intent {} has invalid transaction hash {}",
1235 intent.id, hash.transaction_hash
1236 )
1237 })?;
1238 anyhow::ensure!(
1239 authenticated_payloads
1240 .insert(authenticated_hash, raw_transaction.clone())
1241 .is_none(),
1242 "Execution intent {} has duplicate authenticated transaction hash {}",
1243 intent.id,
1244 hash.transaction_hash
1245 );
1246
1247 if hash.id == current.id {
1248 current_payload = Some(raw_transaction);
1249 }
1250 }
1251 anyhow::ensure!(
1252 !authenticated_payloads.is_empty(),
1253 "Execution intent {} has no persisted signed transaction bytes",
1254 intent.id
1255 );
1256
1257 if intent.status == "broadcast" {
1258 anyhow::ensure!(
1259 current_payload.is_some(),
1260 "Broadcast execution intent {} has no persisted signed transaction bytes",
1261 intent.id
1262 );
1263 }
1264
1265 if intent.status == "signed" {
1266 anyhow::bail!(
1267 "Execution intent {} has a signed transaction {} that was not authorized for broadcast; its nonce remains reserved pending explicit recovery",
1268 intent.id,
1269 tx_hash
1270 );
1271 }
1272
1273 *self.in_flight.lock() = Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
1274 intent_id: intent.id,
1275 nonce,
1276 tx_hash,
1277 purpose,
1278 }));
1279
1280 let plan = if purpose == TransactionPurpose::Swap {
1281 Some(self.restore_swap_plan(&intent)?)
1282 } else {
1283 None
1284 };
1285
1286 if let Some(plan) = &plan
1287 && !intent.acknowledgement_emitted
1288 {
1289 if plan.order.ts_submitted().is_none() {
1290 self.emitter.emit_order_submitted(&plan.order);
1291 }
1292 database
1293 .mark_execution_event_emitted(intent.id, "acknowledgement")
1294 .await?;
1295 }
1296
1297 let executor = self.transaction_executor()?;
1298 let mut prepared = PreparedTransaction {
1299 intent_id: intent.id,
1300 created_block: intent.created_block,
1301 nonce,
1302 tx_hash,
1303 raw_tx: current_payload.unwrap_or_default(),
1304 payload_lease: None,
1305 };
1306
1307 let finality_already_committed = matches!(intent.status.as_str(), "finalized" | "reverted");
1308 if !finality_already_committed {
1309 match executor
1310 .authorize_rebroadcast(&prepared, &intent, purpose)
1311 .await?
1312 {
1313 ReconciliationAuthorization::Rebroadcast => {
1314 match executor.broadcast(&prepared).await? {
1315 BroadcastOutcome::Accepted => {}
1316 BroadcastOutcome::Ambiguous(message) => log::warn!("{message}"),
1317 }
1318 }
1319 ReconciliationAuthorization::Retain => {
1320 log::warn!(
1321 "Rebroadcast of transaction {} was suppressed by verified reconciliation state",
1322 prepared.tx_hash
1323 );
1324 }
1325 ReconciliationAuthorization::ScanReplacement(head) => {
1326 let Some((replacement_hash, replacement_payload)) = executor
1327 .scan_canonical_replacement(&intent, nonce, head, &authenticated_payloads)
1328 .await?
1329 else {
1330 log::warn!(
1331 "Canonical replacement scan for intent {} reached its bounded verified window",
1332 intent.id
1333 );
1334 return Ok(());
1335 };
1336 prepared.tx_hash = replacement_hash;
1337 prepared.raw_tx = replacement_payload;
1338 *self.in_flight.lock() =
1339 Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
1340 intent_id: intent.id,
1341 nonce,
1342 tx_hash: replacement_hash,
1343 purpose,
1344 }));
1345 }
1346 }
1347 }
1348 let outcome = if finality_already_committed {
1349 let receipt = verified_value(
1350 executor.verification.verify_receipt(&tx_hash).await,
1351 "persisted terminal receipt",
1352 )?;
1353 let finality = executor
1354 .receipt_is_stably_finalized(&receipt)
1355 .await?
1356 .ok_or_else(|| {
1357 anyhow::anyhow!(
1358 "Persisted terminal transaction {tx_hash} is not stable at the finalized boundary"
1359 )
1360 })?;
1361 let included = IncludedTransaction {
1362 intent_id: intent.id,
1363 nonce,
1364 tx_hash,
1365 block_number: receipt.block_number,
1366 receipt,
1367 finality,
1368 };
1369
1370 if intent.status == "finalized" {
1371 InclusionOutcome::Finalized(included)
1372 } else {
1373 InclusionOutcome::Reverted(included)
1374 }
1375 } else {
1376 executor.await_finality(&prepared).await?
1377 };
1378
1379 match outcome {
1380 InclusionOutcome::Finalized(mut included) => {
1381 let trace_purpose = match (&plan, purpose) {
1382 (Some(plan), TransactionPurpose::Swap) => match plan.order.order_side() {
1383 OrderSide::Sell => "swap_sell",
1384 OrderSide::Buy => "swap_buy",
1385 },
1386 (None, TransactionPurpose::Wrap) => "wrap",
1387 (None, TransactionPurpose::Approve) => "approve",
1388 _ => anyhow::bail!("Restored transaction purpose is inconsistent"),
1389 };
1390 included.finality.decisions.extend(
1391 verify_finalized_transaction(
1392 &included,
1393 &intent,
1394 nonce,
1395 &prepared.raw_tx,
1396 &executor,
1397 trace_purpose,
1398 )
1399 .await?,
1400 );
1401
1402 if let Some(plan) = plan {
1403 let fill = validate_finalized_swap_fill(&plan, &included)?;
1404 let wallet =
1405 load_verified_wallet_after_fill(&plan, &included, &executor).await?;
1406 if !finality_already_committed {
1407 executor
1408 .commit_verified_finality(
1409 &included,
1410 TransactionStatus::Finalized,
1411 &wallet.decisions,
1412 )
1413 .await?;
1414 }
1415 complete_finalized_swap(
1416 &plan,
1417 intent.id,
1418 included.tx_hash,
1419 fill,
1420 wallet,
1421 &executor,
1422 &self.emitter,
1423 )
1424 .await?;
1425 executor.release_slot();
1426 } else {
1427 let postconditions = self
1428 .verify_recovered_operator_transaction(
1429 &intent, purpose, &included, &executor,
1430 )
1431 .await?;
1432
1433 if !finality_already_committed {
1434 executor
1435 .commit_verified_finality(
1436 &included,
1437 TransactionStatus::Finalized,
1438 &postconditions,
1439 )
1440 .await?;
1441 }
1442 database
1443 .mark_execution_event_emitted(intent.id, "terminal")
1444 .await?;
1445 executor.release_slot();
1446 }
1447 }
1448 InclusionOutcome::Reverted(mut included) => {
1449 let trace_purpose = match (&plan, purpose) {
1450 (Some(plan), TransactionPurpose::Swap) => match plan.order.order_side() {
1451 OrderSide::Sell => "swap_sell",
1452 OrderSide::Buy => "swap_buy",
1453 },
1454 (None, TransactionPurpose::Wrap) => "wrap",
1455 (None, TransactionPurpose::Approve) => "approve",
1456 _ => anyhow::bail!("Restored transaction purpose is inconsistent"),
1457 };
1458 included.finality.decisions.extend(
1459 verify_finalized_transaction(
1460 &included,
1461 &intent,
1462 nonce,
1463 &prepared.raw_tx,
1464 &executor,
1465 trace_purpose,
1466 )
1467 .await?,
1468 );
1469
1470 if !finality_already_committed {
1471 executor
1472 .commit_verified_finality(&included, TransactionStatus::Reverted, &[])
1473 .await?;
1474 }
1475
1476 if let Some(plan) = plan
1477 && plan.order.status() != OrderStatus::Rejected
1478 {
1479 send_reverted_order(&self.emitter, &plan.order, &included)?;
1480 }
1481 database
1482 .mark_execution_event_emitted(intent.id, "terminal")
1483 .await?;
1484 executor.release_slot();
1485 }
1486 InclusionOutcome::Pending(message) => log::warn!("{message}"),
1487 }
1488 Ok(())
1489 }
1490
1491 async fn verify_recovered_operator_transaction(
1497 &self,
1498 intent: &ExecutionIntentRow,
1499 purpose: TransactionPurpose,
1500 included: &IncludedTransaction,
1501 executor: &TransactionExecutor,
1502 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
1503 let (to, input, value) = persisted_call_fields(intent)?;
1504
1505 match purpose {
1506 TransactionPurpose::Wrap => {
1507 verify_wrap_balance_increase(executor, &to, value, included).await
1508 }
1509 TransactionPurpose::Approve => {
1510 let call = ERC20::approveCall::abi_decode(&input)
1511 .with_context(|| "persisted approve calldata is invalid")?;
1512 verify_approve_allowance(executor, &to, &call.spender, call.amount, included).await
1513 }
1514 TransactionPurpose::Swap => {
1515 unreachable!("swap intents restore a swap plan")
1516 }
1517 }
1518 }
1519
1520 fn ensure_transaction_ready(&self, purpose: TransactionPurpose) -> anyhow::Result<()> {
1521 if !self.core.is_connected() {
1522 anyhow::bail!("Blockchain execution client is not connected");
1523 }
1524
1525 {
1526 let slot = self.in_flight.lock();
1527 if let Some(in_flight) = *slot {
1528 return Err(in_flight_limit_error(&in_flight));
1529 }
1530 }
1531
1532 if !self.cache.has_database() {
1533 anyhow::bail!(
1534 "No durable store configured; refusing to submit a {} transaction",
1535 purpose.as_str()
1536 );
1537 }
1538 Ok(())
1539 }
1540
1541 fn prepare_swap(&self, cmd: &SubmitOrder, order: &OrderAny) -> anyhow::Result<SwapPlan> {
1548 let instrument_id = order.instrument_id();
1549 let pool = self.resolve_pool(&instrument_id)?;
1550
1551 if order.order_type() != OrderType::Market {
1552 anyhow::bail!(
1553 "Unsupported order type {}; only Market is supported",
1554 order.order_type()
1555 );
1556 }
1557
1558 if !matches!(order.order_side(), OrderSide::Buy | OrderSide::Sell) {
1559 anyhow::bail!(
1560 "Unsupported order side {}; only Buy and Sell are supported",
1561 order.order_side()
1562 );
1563 }
1564
1565 if order.is_quote_quantity() {
1566 anyhow::bail!(
1567 "Quote-denominated quantities are not supported; quantity must be denominated in the base token"
1568 );
1569 }
1570
1571 let fee = pool
1572 .fee
1573 .ok_or_else(|| anyhow::anyhow!("Pool {instrument_id} has no fee tier"))?;
1574 let fee = U24::try_from(fee)
1575 .map_err(|_| anyhow::anyhow!("Pool {instrument_id} fee {fee} exceeds uint24"))?;
1576
1577 let base_token = pool.get_base_token();
1578 let quote_token = pool.get_quote_token();
1579 let quote_currency = Currency::new_checked(
1580 "e_token.symbol,
1581 quote_token.decimals,
1582 0,
1583 "e_token.name,
1584 CurrencyType::Crypto,
1585 )?;
1586 let (token_in, token_out) =
1587 swap_token_pair(order.order_side(), base_token.address, quote_token.address)?;
1588
1589 if !self
1590 .transaction_limits
1591 .allowed_token_pairs
1592 .contains(&(token_in, token_out))
1593 {
1594 anyhow::bail!(
1595 "Token pair {token_in} -> {token_out} is not in the `allowed_token_pairs` allowlist"
1596 );
1597 }
1598
1599 let base_amount = quantity_to_raw_amount(order.quantity(), base_token.decimals)?;
1600 if base_amount > U256::from(self.transaction_limits.max_order_amount) {
1601 anyhow::bail!(
1602 "Order amount {base_amount} exceeds the configured `max_order_amount` {}",
1603 self.transaction_limits.max_order_amount
1604 );
1605 }
1606
1607 let slippage_bps = match cmd
1608 .params
1609 .as_ref()
1610 .and_then(|params| params.get_u64("slippage_bps"))
1611 {
1612 Some(value) => u32::try_from(value).map_err(|_| {
1613 anyhow::anyhow!("slippage_bps parameter {value} exceeds the u32 range")
1614 })?,
1615 None => self.transaction_limits.slippage_bps,
1616 };
1617
1618 if slippage_bps > self.transaction_limits.max_slippage_bps {
1619 anyhow::bail!(
1620 "Slippage {slippage_bps} bps exceeds the configured `max_slippage_bps` {}",
1621 self.transaction_limits.max_slippage_bps
1622 );
1623 }
1624
1625 let quote_spend_ceiling = if order.order_side() == OrderSide::Buy {
1626 let ceiling = self
1627 .transaction_limits
1628 .quote_spend_limits
1629 .get(&(token_in, token_out))
1630 .ok_or_else(|| {
1631 anyhow::anyhow!(
1632 "No `quote_spend_limits` entry for BUY token pair {token_in} -> {token_out}"
1633 )
1634 })?;
1635 anyhow::ensure!(
1636 ceiling.spend_token == quote_token.address,
1637 "Quote spend limit for {token_in} -> {token_out} is denominated in {}, expected quote token {}",
1638 ceiling.spend_token,
1639 quote_token.address
1640 );
1641 anyhow::ensure!(
1642 ceiling.spend_token_decimals == quote_token.decimals,
1643 "Quote spend limit for token {} uses {} decimals, expected pool quote-token decimals {}",
1644 ceiling.spend_token,
1645 ceiling.spend_token_decimals,
1646 quote_token.decimals
1647 );
1648 Some(ceiling)
1649 } else {
1650 None
1651 };
1652
1653 let profiler = self
1654 .core
1655 .cache()
1656 .pool_profiler(&instrument_id)
1657 .cloned()
1658 .ok_or_else(|| {
1659 anyhow::anyhow!(
1660 "No pool profiler for {instrument_id}; an active data subscription is required to quote the swap"
1661 )
1662 })?;
1663
1664 if !profiler.is_initialized {
1665 anyhow::bail!("Pool profiler for {instrument_id} is not initialized");
1666 }
1667 let profiler_position = profiler.last_processed_event.clone().ok_or_else(|| {
1668 anyhow::anyhow!("Pool profiler for {instrument_id} has processed no events")
1669 })?;
1670
1671 let zero_for_one = token_in == pool.token0.address;
1672 let (amount_in, quoted_amount_out) = match order.order_side() {
1673 OrderSide::Sell => {
1674 let quote = profiler
1675 .swap_exact_in(base_amount, zero_for_one, None)
1676 .map_err(|e| anyhow::anyhow!("Swap quote failed for {instrument_id}: {e}"))?;
1677 let amount_filled = if zero_for_one {
1678 quote.amount0
1679 } else {
1680 quote.amount1
1681 };
1682
1683 if amount_filled != I256::from(base_amount) {
1684 anyhow::bail!(
1685 "Local quote for {instrument_id} filled {amount_filled} of the {base_amount} order amount; pool liquidity cannot fill the order"
1686 );
1687 }
1688 (base_amount, exact_output_amount("e, zero_for_one)?)
1689 }
1690 OrderSide::Buy => {
1691 let quote = profiler
1692 .swap_exact_out(base_amount, zero_for_one, None)
1693 .map_err(|e| anyhow::anyhow!("Swap quote failed for {instrument_id}: {e}"))?;
1694 let amount_in = quote.get_input_amount();
1695 if amount_in.is_zero() {
1696 anyhow::bail!("Local quote for {instrument_id} produced a zero quote input");
1697 }
1698 let ceiling = quote_spend_ceiling.ok_or_else(|| {
1699 anyhow::anyhow!(
1700 "No `quote_spend_limits` entry for BUY token pair {token_in} -> {token_out}"
1701 )
1702 })?;
1703
1704 if amount_in > ceiling.max_amount {
1705 anyhow::bail!(
1706 "BUY quote amount {amount_in} exceeds the configured `quote_spend_limits` maximum {} for {token_in} -> {token_out}",
1707 ceiling.max_amount
1708 );
1709 }
1710 (amount_in, base_amount)
1711 }
1712 };
1713 let min_amount_out = derive_min_amount_out(quoted_amount_out, slippage_bps)?;
1714
1715 self.ensure_transaction_ready(TransactionPurpose::Swap)?;
1716
1717 if self.signer.is_none() {
1718 anyhow::bail!("Signer not initialized; connect the client first");
1719 }
1720
1721 let pool_address = pool.address;
1722 let factory = self.uniswap_v3_factory()?;
1723 anyhow::ensure!(
1724 pool.dex.factory == factory,
1725 "Pool {instrument_id} references factory {}, expected registered factory {factory}",
1726 pool.dex.factory
1727 );
1728
1729 Ok(SwapPlan {
1730 order: order.clone(),
1731 pool,
1732 quote_currency,
1733 instrument_id,
1734 pool_address,
1735 router: self.router_addresses[0],
1736 factory,
1737 weth: self.weth_address,
1738 token_in,
1739 token_out,
1740 fee,
1741 amount_in,
1742 min_amount_out,
1743 slippage_bps,
1744 quote_spend_ceiling: quote_spend_ceiling.copied(),
1745 profiler_position: Some(profiler_position),
1746 })
1747 }
1748}
1749
1750#[derive(Debug, Clone, PartialEq, Eq)]
1752pub struct PayloadStorageCheck {
1753 pub protected: bool,
1755 pub deployment_id: Option<String>,
1757 pub plaintext_rows: u64,
1759 pub original_rows: u64,
1761 pub replacement_rows: u64,
1763 pub authenticated_rows: u64,
1765 pub key_ids: Vec<String>,
1767 pub read_roles: Vec<String>,
1769}
1770
1771impl From<ExecutionPayloadCheck> for PayloadStorageCheck {
1772 fn from(value: ExecutionPayloadCheck) -> Self {
1773 Self {
1774 protected: value.protected,
1775 deployment_id: value.deployment_id,
1776 plaintext_rows: value.plaintext_rows,
1777 original_rows: value.original_rows,
1778 replacement_rows: value.replacement_rows,
1779 authenticated_rows: value.authenticated_rows,
1780 key_ids: value.key_ids,
1781 read_roles: value.read_roles,
1782 }
1783 }
1784}
1785
1786#[derive(Debug, Clone, Copy)]
1788struct InFlightTransaction {
1789 intent_id: i64,
1790 nonce: u64,
1791 tx_hash: B256,
1792 purpose: TransactionPurpose,
1793}
1794
1795#[derive(Debug, Clone, Copy)]
1796struct RecoveryTransaction {
1797 intent_id: i64,
1798 nonce: u64,
1799 purpose: TransactionPurpose,
1800}
1801
1802#[derive(Debug, Clone, Copy)]
1810enum InFlightSlot {
1811 Preparing(TransactionPurpose),
1813 Recovering(RecoveryTransaction),
1815 AwaitingFinality(InFlightTransaction),
1817}
1818
1819#[derive(Debug, Clone)]
1820struct IncludedTransaction {
1821 intent_id: i64,
1822 nonce: u64,
1823 tx_hash: B256,
1824 block_number: u64,
1825 receipt: RpcTransactionReceipt,
1826 finality: StableFinality,
1827}
1828
1829#[derive(Debug, Clone)]
1830struct StableFinality {
1831 decisions: Vec<ExecutionVerificationDecision>,
1832 inclusion_header: ExecutionVerifiedHeader,
1833 finalized_headers: Vec<ExecutionVerifiedHeader>,
1834}
1835
1836#[derive(Debug, Clone, Copy)]
1837enum TransactionAuthorization {
1838 Wrap {
1839 weth: Address,
1840 },
1841 Approve {
1842 token: Address,
1843 router: Address,
1844 amount: U256,
1845 },
1846}
1847
1848fn in_flight_limit_error(slot: &InFlightSlot) -> anyhow::Error {
1850 match slot {
1851 InFlightSlot::Preparing(purpose) => anyhow::anyhow!(
1852 "A {} transaction is being prepared; at most one transaction can be in flight",
1853 purpose.as_str()
1854 ),
1855 InFlightSlot::Recovering(recovery) => anyhow::anyhow!(
1856 "Execution intent {} ({}, nonce {}) retains signer ownership pending recovery; at most one transaction can be in flight",
1857 recovery.intent_id,
1858 recovery.purpose.as_str(),
1859 recovery.nonce
1860 ),
1861 InFlightSlot::AwaitingFinality(in_flight) => anyhow::anyhow!(
1862 "Transaction {} (intent {}, {}, nonce {}) is still awaiting finality; at most one transaction can be in flight",
1863 in_flight.tx_hash,
1864 in_flight.intent_id,
1865 in_flight.purpose.as_str(),
1866 in_flight.nonce
1867 ),
1868 }
1869}
1870
1871fn release_preparing_slot(in_flight: &Mutex<Option<InFlightSlot>>) {
1876 let mut slot = in_flight.lock();
1877 if matches!(*slot, Some(InFlightSlot::Preparing(_))) {
1878 *slot = None;
1879 }
1880}
1881
1882fn release_preparing_if_reservation_not_committed(
1883 in_flight: &Mutex<Option<InFlightSlot>>,
1884 error: &anyhow::Error,
1885) {
1886 if reservation_failure_proven_not_committed(error) {
1887 release_preparing_slot(in_flight);
1888 }
1889}
1890
1891#[derive(Debug)]
1892struct TransactionLimits {
1893 allowed_token_pairs: HashSet<(Address, Address)>,
1894 quote_spend_limits: HashMap<(Address, Address), QuoteSpendCeiling>,
1895 slippage_bps: u32,
1896 max_slippage_bps: u32,
1897 max_order_amount: u64,
1898 deadline_seconds: u64,
1899 max_quote_age_blocks: u64,
1900 receipt_timeout_secs: u64,
1901}
1902
1903#[derive(Debug, Clone, Copy)]
1904struct QuoteSpendCeiling {
1905 spend_token: Address,
1906 spend_token_decimals: u8,
1907 max_amount: U256,
1908}
1909
1910struct PreparedTransaction {
1912 intent_id: i64,
1913 created_block: u64,
1914 nonce: u64,
1915 tx_hash: B256,
1916 raw_tx: Vec<u8>,
1917 payload_lease: Option<ExecutionPayloadLease>,
1918}
1919
1920impl Debug for PreparedTransaction {
1921 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1922 f.debug_struct(stringify!(PreparedTransaction))
1923 .field("intent_id", &self.intent_id)
1924 .field("created_block", &self.created_block)
1925 .field("nonce", &self.nonce)
1926 .field("tx_hash", &self.tx_hash)
1927 .field("raw_tx", &"<redacted>")
1928 .field(
1929 "payload_lease",
1930 &self.payload_lease.as_ref().map(|_| "held"),
1931 )
1932 .finish()
1933 }
1934}
1935
1936#[derive(Debug)]
1938enum BroadcastOutcome {
1939 Accepted,
1941 Ambiguous(String),
1943}
1944
1945#[derive(Debug)]
1947enum InclusionOutcome {
1948 Finalized(IncludedTransaction),
1950 Reverted(IncludedTransaction),
1952 Pending(String),
1955}
1956
1957enum ReconciliationAuthorization {
1958 Rebroadcast,
1959 Retain,
1960 ScanReplacement(VerifiedBlockHeader),
1961}
1962
1963fn verified_value<T>(outcome: VerificationOutcome<T>, context: &str) -> anyhow::Result<T> {
1964 required_verification(outcome, context).map(|verified| verified.value)
1965}
1966
1967fn required_verification<T>(
1968 outcome: VerificationOutcome<T>,
1969 context: &str,
1970) -> anyhow::Result<Verified<T>> {
1971 match outcome {
1972 VerificationOutcome::Verified(verified) => Ok(verified),
1973 VerificationOutcome::Disagreement(_) => {
1974 anyhow::bail!("{context} verification disagreed")
1975 }
1976 VerificationOutcome::Unavailable(_) => {
1977 anyhow::bail!("{context} verification is unavailable")
1978 }
1979 VerificationOutcome::Retryable(_) => {
1980 anyhow::bail!("{context} verification is retryable")
1981 }
1982 VerificationOutcome::LocallyInvalid(_) => {
1983 anyhow::bail!("{context} verification is locally invalid")
1984 }
1985 }
1986}
1987
1988fn validate_transaction_authorization(
1989 authorization: Option<&TransactionAuthorization>,
1990 to: Address,
1991 value: U256,
1992 input: &[u8],
1993) -> anyhow::Result<()> {
1994 match authorization {
1995 None => Ok(()),
1996 Some(TransactionAuthorization::Wrap { weth }) => {
1997 anyhow::ensure!(
1998 to == *weth && !value.is_zero() && input == WETH9::depositCall::SELECTOR,
1999 "Wrap authorization does not match the transaction call"
2000 );
2001 Ok(())
2002 }
2003 Some(TransactionAuthorization::Approve {
2004 token,
2005 router,
2006 amount,
2007 }) => {
2008 let expected = ERC20::approveCall {
2009 spender: *router,
2010 amount: *amount,
2011 }
2012 .abi_encode();
2013 anyhow::ensure!(
2014 to == *token && value.is_zero() && input == expected,
2015 "Approve authorization does not match the transaction call"
2016 );
2017 Ok(())
2018 }
2019 }
2020}
2021
2022fn verification_decision<T>(
2023 verified: &Verified<T>,
2024 height_start: Option<u64>,
2025 height_end: Option<u64>,
2026) -> ExecutionVerificationDecision {
2027 ExecutionVerificationDecision {
2028 read_class: verified.read.as_str(),
2029 height_start,
2030 height_end,
2031 normalized_value_digest: verified.normalized_value_digest.to_string(),
2032 }
2033}
2034
2035fn parse_verified_header(header: &ExecutionVerifiedHeader) -> anyhow::Result<VerifiedBlockHeader> {
2036 Ok(VerifiedBlockHeader {
2037 number: header.number,
2038 hash: B256::from_str(&header.hash).context("Durable finalized header hash is invalid")?,
2039 parent_hash: B256::from_str(&header.parent_hash)
2040 .context("Durable finalized parent hash is invalid")?,
2041 timestamp: header.timestamp,
2042 base_fee_per_gas: header.base_fee_per_gas,
2043 })
2044}
2045
2046fn durable_verified_header(header: &VerifiedBlockHeader) -> ExecutionVerifiedHeader {
2047 ExecutionVerifiedHeader {
2048 number: header.number,
2049 hash: header.hash.to_string(),
2050 parent_hash: header.parent_hash.to_string(),
2051 timestamp: header.timestamp,
2052 base_fee_per_gas: header.base_fee_per_gas,
2053 }
2054}
2055
2056#[derive(Debug, Clone)]
2063struct TransactionExecutor {
2064 http_rpc_client: Arc<BlockchainHttpRpcClient>,
2065 verification: VerificationCoordinator,
2066 manifest_version: String,
2067 manifest_digest: String,
2068 deployment_manifest: Arc<BlockchainDeploymentManifest>,
2069 provider_ids: Vec<String>,
2070 operator_ids: Vec<String>,
2071 failure_domain_ids: Vec<String>,
2072 database: BlockchainCacheDatabase,
2073 signer: Arc<PrivateKeySigner>,
2074 payload_keys: Arc<PayloadKeySet>,
2075 in_flight: Arc<Mutex<Option<InFlightSlot>>>,
2076 wallet_balance: Arc<Mutex<WalletBalance>>,
2077 account_id: AccountId,
2078 wallet_address: Address,
2079 chain_id: u32,
2080 max_fee_per_gas_wei: u64,
2081 base_fee_buffer_bps: u32,
2082 gas_limit: u64,
2083 gas_buffer_bps: u32,
2084 receipt_timeout: Duration,
2085 receipt_max_polls: u32,
2086}
2087
2088impl TransactionExecutor {
2089 async fn transact(
2095 &self,
2096 to: Address,
2097 value: U256,
2098 input: Bytes,
2099 purpose: TransactionPurpose,
2100 client_order_id: Option<ClientOrderId>,
2101 authorization: TransactionAuthorization,
2102 ) -> anyhow::Result<IncludedTransaction> {
2103 self.claim_slot(purpose)?;
2104 let now_unix_secs = current_unix_secs()?;
2105 let decision_header = match required_verification(
2106 self.verification
2107 .verify_decision_header(now_unix_secs)
2108 .await,
2109 "operator decision header",
2110 ) {
2111 Ok(header) => header,
2112 Err(e) => {
2113 release_preparing_slot(&self.in_flight);
2114 return Err(e);
2115 }
2116 };
2117 let created_block = decision_header.value.number;
2118 let intent = ExecutionIntentInsert {
2119 chain_id: self.chain_id,
2120 wallet_address: self.wallet_address.to_string(),
2121 purpose: purpose.as_str().to_string(),
2122 client_order_id: client_order_id.map(|id| id.to_string()),
2123 trader_id: None,
2124 strategy_id: None,
2125 account_id: None,
2126 instrument_id: None,
2127 pool_address: None,
2128 transaction_to: to.to_string(),
2129 transaction_input: hex::encode_prefixed(&input),
2130 transaction_value: value.to_string(),
2131 amount_in: None,
2132 created_block,
2133 };
2134 let intent = match self.database.reserve_execution_intent(&intent).await {
2135 Ok(intent) => intent,
2136 Err(e) => {
2137 release_preparing_if_reservation_not_committed(&self.in_flight, &e);
2138 return Err(e);
2139 }
2140 };
2141 let prepared = match self
2142 .prepare_and_sign(
2143 intent.id,
2144 intent.created_block,
2145 to,
2146 value,
2147 input,
2148 &authorization,
2149 decision_header,
2150 )
2151 .await
2152 {
2153 Ok(prepared) => prepared,
2154 Err(e) => {
2155 if self
2156 .database
2157 .mark_execution_intent_recoverable(intent.id)
2158 .await
2159 .is_ok()
2160 {
2161 release_preparing_slot(&self.in_flight);
2162 }
2163 return Err(e);
2164 }
2165 };
2166 self.fill_and_persist(&prepared, purpose).await?;
2167
2168 match self.broadcast(&prepared).await? {
2169 BroadcastOutcome::Accepted => {}
2170 BroadcastOutcome::Ambiguous(message) => log::warn!("{message}"),
2171 }
2172
2173 match self.await_finality(&prepared).await? {
2174 InclusionOutcome::Finalized(mut included) => {
2175 included.finality.decisions.extend(
2176 verify_finalized_transaction(
2177 &included,
2178 &intent,
2179 prepared.nonce,
2180 &prepared.raw_tx,
2181 self,
2182 purpose.as_str(),
2183 )
2184 .await?,
2185 );
2186 Ok(included)
2187 }
2188 InclusionOutcome::Reverted(mut included) => {
2189 included.finality.decisions.extend(
2190 verify_finalized_transaction(
2191 &included,
2192 &intent,
2193 prepared.nonce,
2194 &prepared.raw_tx,
2195 self,
2196 purpose.as_str(),
2197 )
2198 .await?,
2199 );
2200 self.commit_verified_finality(&included, TransactionStatus::Reverted, &[])
2201 .await?;
2202 self.database
2203 .mark_execution_event_emitted(prepared.intent_id, "terminal")
2204 .await?;
2205 self.release_slot();
2206 anyhow::bail!("Transaction {} reverted on-chain", included.tx_hash)
2207 }
2208 InclusionOutcome::Pending(message) => anyhow::bail!(message),
2209 }
2210 }
2211
2212 fn claim_slot(&self, purpose: TransactionPurpose) -> anyhow::Result<()> {
2215 let mut slot = self.in_flight.lock();
2216 if let Some(in_flight) = *slot {
2217 return Err(in_flight_limit_error(&in_flight));
2218 }
2219 *slot = Some(InFlightSlot::Preparing(purpose));
2220 Ok(())
2221 }
2222
2223 #[expect(
2226 clippy::too_many_arguments,
2227 reason = "Security-critical transaction fields stay explicit at the signing boundary"
2228 )]
2229 async fn prepare_and_sign(
2230 &self,
2231 intent_id: i64,
2232 created_block: u64,
2233 to: Address,
2234 value: U256,
2235 input: Bytes,
2236 authorization: &TransactionAuthorization,
2237 decision_header: Verified<VerifiedBlockHeader>,
2238 ) -> anyhow::Result<PreparedTransaction> {
2239 self.prepare_and_sign_with_anchors(
2240 intent_id,
2241 created_block,
2242 to,
2243 value,
2244 input,
2245 None,
2246 Some(authorization),
2247 Some(decision_header),
2248 )
2249 .await
2250 }
2251
2252 async fn prepare_and_sign_swap(
2253 &self,
2254 intent_id: i64,
2255 created_block: u64,
2256 to: Address,
2257 value: U256,
2258 input: Bytes,
2259 anchors: &SwapQuoteAnchors,
2260 ) -> anyhow::Result<PreparedTransaction> {
2261 self.prepare_and_sign_with_anchors(
2262 intent_id,
2263 created_block,
2264 to,
2265 value,
2266 input,
2267 Some(anchors),
2268 None,
2269 None,
2270 )
2271 .await
2272 }
2273
2274 #[expect(
2275 clippy::too_many_arguments,
2276 reason = "Security-critical transaction fields and verification anchors stay explicit"
2277 )]
2278 async fn prepare_and_sign_with_anchors(
2279 &self,
2280 intent_id: i64,
2281 created_block: u64,
2282 to: Address,
2283 value: U256,
2284 input: Bytes,
2285 swap_anchors: Option<&SwapQuoteAnchors>,
2286 authorization: Option<&TransactionAuthorization>,
2287 decision_header: Option<Verified<VerifiedBlockHeader>>,
2288 ) -> anyhow::Result<PreparedTransaction> {
2289 let expected_chain_id = u64::from(self.chain_id);
2290 let chain_id_verification = required_verification(
2291 self.verification.verify_chain_id().await,
2292 "pre-sign chain ID",
2293 )?;
2294 let actual_chain_id = chain_id_verification.value;
2295 anyhow::ensure!(
2296 actual_chain_id == expected_chain_id,
2297 "Verified chain ID does not match the transaction chain"
2298 );
2299 let decision_header_verification = if let Some(anchors) = swap_anchors {
2300 required_verification(
2301 self.verification.verify_block(anchors.state.number).await,
2302 "pre-sign swap decision header reread",
2303 )?
2304 } else {
2305 match decision_header {
2306 Some(verified) => verified,
2307 None => {
2308 let now_unix_secs = current_unix_secs()?;
2309 required_verification(
2310 self.verification
2311 .verify_decision_header(now_unix_secs)
2312 .await,
2313 "pre-sign decision header",
2314 )?
2315 }
2316 }
2317 };
2318 let decision_header = decision_header_verification.value;
2319
2320 if let Some(anchors) = swap_anchors {
2321 anyhow::ensure!(
2322 decision_header == anchors.state,
2323 "Verified swap decision header changed before signing"
2324 );
2325 }
2326 let decision_ancestry = self.verify_decision_ancestry(decision_header).await?;
2327 validate_transaction_authorization(authorization, to, value, &input)?;
2328 let deployment_verification = required_verification(
2329 self.verification
2330 .verify_deployment_manifest(&self.deployment_manifest, decision_header.number)
2331 .await,
2332 "pre-sign deployment manifest",
2333 )?;
2334 let authorization_decisions = match authorization {
2335 Some(authorization) => {
2336 self.verify_transaction_authorization(authorization, decision_header.number)
2337 .await?
2338 }
2339 None => Vec::new(),
2340 };
2341 let base_fee_per_gas_wei = decision_header.base_fee_per_gas.ok_or_else(|| {
2342 anyhow::anyhow!(
2343 "Verified decision block {} has no base fee",
2344 decision_header.number
2345 )
2346 })?;
2347 let priority_fee_verification = required_verification(
2348 self.verification.verify_priority_fee().await,
2349 "pre-sign priority fee",
2350 )?;
2351 let priority_fee_per_gas_wei = priority_fee_verification.value;
2352 let (max_fee_per_gas, max_priority_fee_per_gas) = derive_fees(
2353 base_fee_per_gas_wei,
2354 priority_fee_per_gas_wei,
2355 self.base_fee_buffer_bps,
2356 u128::from(self.max_fee_per_gas_wei),
2357 )?;
2358 let gas_estimate_verification = required_verification(
2359 self.verification
2360 .verify_gas_estimate(
2361 &self.wallet_address,
2362 &to,
2363 value,
2364 &input,
2365 decision_header.number,
2366 )
2367 .await,
2368 "pre-sign gas estimate",
2369 )?;
2370 let gas_estimate = gas_estimate_verification.value;
2371 let gas_limit = derive_gas_limit(gas_estimate, self.gas_buffer_bps, self.gas_limit)?;
2372 let max_gas_cost = U256::from(gas_limit)
2373 .checked_mul(U256::from(max_fee_per_gas))
2374 .ok_or_else(|| anyhow::anyhow!("Maximum gas cost overflow"))?;
2375 let max_transaction_cost = value
2376 .checked_add(max_gas_cost)
2377 .ok_or_else(|| anyhow::anyhow!("Maximum transaction cost overflow"))?;
2378 let native_balance_verification = required_verification(
2379 self.verification
2380 .verify_balance(&self.wallet_address, decision_header.number)
2381 .await,
2382 "pre-sign native balance",
2383 )?;
2384 let native_balance = native_balance_verification.value;
2385
2386 if native_balance < max_transaction_cost {
2387 anyhow::bail!(
2388 "Native currency balance {native_balance} wei is below maximum transaction cost {max_transaction_cost} wei"
2389 );
2390 }
2391 let decision_height = Some(decision_header.number);
2392 let mut decisions = vec![
2393 verification_decision(&chain_id_verification, None, None),
2394 verification_decision(
2395 &decision_header_verification,
2396 decision_height,
2397 decision_height,
2398 ),
2399 verification_decision(&deployment_verification, decision_height, decision_height),
2400 verification_decision(&priority_fee_verification, None, None),
2401 verification_decision(&gas_estimate_verification, decision_height, decision_height),
2402 verification_decision(
2403 &native_balance_verification,
2404 decision_height,
2405 decision_height,
2406 ),
2407 ];
2408 decisions.extend(decision_ancestry);
2409 decisions.extend(authorization_decisions);
2410 if let Some(anchors) = swap_anchors {
2411 decisions.extend(self.verify_swap_anchors_before_sign(anchors).await?);
2412 decisions.extend(anchors.precondition_decisions.iter().cloned());
2413 } else {
2414 decisions.extend(self.verify_pre_sign_header_fence(decision_header).await?);
2415 }
2416 let canonical_nonce_verification = required_verification(
2417 self.verification
2418 .verify_transaction_count(&self.wallet_address, decision_header.number)
2419 .await,
2420 "pre-sign canonical nonce reread",
2421 )?;
2422 let pending_nonce_verification = required_verification(
2423 self.verification
2424 .verify_pending_transaction_count(&self.wallet_address)
2425 .await,
2426 "pre-sign pending nonce reread",
2427 )?;
2428 anyhow::ensure!(
2429 pending_nonce_verification.value == canonical_nonce_verification.value,
2430 "Pending nonce does not match the verified canonical nonce"
2431 );
2432 let nonce = canonical_nonce_verification.value;
2433 decisions.push(verification_decision(
2434 &canonical_nonce_verification,
2435 decision_height,
2436 decision_height,
2437 ));
2438 decisions.push(verification_decision(
2439 &pending_nonce_verification,
2440 None,
2441 None,
2442 ));
2443 let tx = build_eip1559_transaction(
2444 expected_chain_id,
2445 nonce,
2446 gas_limit,
2447 max_fee_per_gas,
2448 max_priority_fee_per_gas,
2449 to,
2450 value,
2451 input,
2452 );
2453 let wallet_address = self.wallet_address.to_string();
2454 self.database
2455 .assign_execution_intent_nonce_verified(&ExecutionNonceAssignment {
2456 intent_id,
2457 chain_id: self.chain_id,
2458 wallet_address: &wallet_address,
2459 nonce,
2460 manifest_version: &self.manifest_version,
2461 manifest_digest: &self.manifest_digest,
2462 provider_ids: &self.provider_ids,
2463 operator_ids: &self.operator_ids,
2464 failure_domain_ids: &self.failure_domain_ids,
2465 decisions: &decisions,
2466 })
2467 .await?;
2468 let payload_lease = self
2469 .database
2470 .acquire_execution_payload_lease(&self.payload_keys)
2471 .await?;
2472 let (tx_hash, raw_tx) = sign_eip1559_transaction(tx, &self.signer).await?;
2473
2474 Ok(PreparedTransaction {
2475 intent_id,
2476 created_block,
2477 nonce,
2478 tx_hash,
2479 raw_tx,
2480 payload_lease: Some(payload_lease),
2481 })
2482 }
2483
2484 async fn verify_pre_sign_header_fence(
2485 &self,
2486 target: VerifiedBlockHeader,
2487 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
2488 let checkpoint = required_verification(
2489 self.verification.verify_checkpoint().await,
2490 "pre-sign checkpoint reread",
2491 )?;
2492 let header = required_verification(
2493 self.verification.verify_block(target.number).await,
2494 "pre-sign decision header reread",
2495 )?;
2496 anyhow::ensure!(
2497 header.value == target,
2498 "Decision header changed before signing"
2499 );
2500 Ok(vec![
2501 verification_decision(
2502 &checkpoint,
2503 Some(checkpoint.value.number),
2504 Some(checkpoint.value.number),
2505 ),
2506 verification_decision(&header, Some(target.number), Some(target.number)),
2507 ])
2508 }
2509
2510 async fn verify_decision_ancestry(
2511 &self,
2512 target: VerifiedBlockHeader,
2513 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
2514 let checkpoint = required_verification(
2515 self.verification.verify_checkpoint().await,
2516 "pre-sign checkpoint",
2517 )?;
2518 anyhow::ensure!(
2519 checkpoint.value.number <= target.number,
2520 "Pre-sign decision header precedes the trusted checkpoint"
2521 );
2522 let mut decisions = vec![verification_decision(
2523 &checkpoint,
2524 Some(checkpoint.value.number),
2525 Some(checkpoint.value.number),
2526 )];
2527 let wallet_address = self.wallet_address.to_string();
2528 let position = self
2529 .database
2530 .load_execution_verification_position(
2531 self.chain_id,
2532 &wallet_address,
2533 &self.manifest_version,
2534 &self.manifest_digest,
2535 )
2536 .await?
2537 .ok_or_else(|| anyhow::anyhow!("Execution verification ledger is not initialized"))?;
2538 let durable_tip = parse_verified_header(&position.finalized_tip)?;
2539 anyhow::ensure!(
2540 durable_tip.number >= checkpoint.value.number && durable_tip.number <= target.number,
2541 "Pre-sign decision header does not extend the durable finalized header tip"
2542 );
2543 let durable_tip_verification = required_verification(
2544 self.verification.verify_block(durable_tip.number).await,
2545 "pre-sign durable finalized tip",
2546 )?;
2547 anyhow::ensure!(
2548 durable_tip_verification.value == durable_tip,
2549 "Durable finalized header tip conflicts with independent sources"
2550 );
2551
2552 if durable_tip != checkpoint.value {
2553 decisions.push(verification_decision(
2554 &durable_tip_verification,
2555 Some(durable_tip.number),
2556 Some(durable_tip.number),
2557 ));
2558 }
2559 let mut cursor = durable_tip;
2560 while cursor.number < target.number {
2561 let end = cursor.number.saturating_add(4_096).min(target.number);
2562 let start = cursor.number.saturating_add(1);
2563 let ancestry = required_verification(
2564 self.verification.verify_header_window(cursor, end).await,
2565 "pre-sign decision ancestry",
2566 )?;
2567 cursor = *ancestry
2568 .value
2569 .last()
2570 .expect("nonempty decision ancestry advances the cursor");
2571 decisions.push(verification_decision(&ancestry, Some(start), Some(end)));
2572 }
2573 anyhow::ensure!(
2574 cursor == target,
2575 "Pre-sign decision header conflicts with its trusted ancestry"
2576 );
2577 Ok(decisions)
2578 }
2579
2580 async fn verify_transaction_authorization(
2581 &self,
2582 authorization: &TransactionAuthorization,
2583 block: u64,
2584 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
2585 match *authorization {
2586 TransactionAuthorization::Wrap { weth } => {
2587 let call = ERC20::balanceOfCall {
2588 account: self.wallet_address,
2589 }
2590 .abi_encode();
2591 let balance = required_verification(
2592 self.verification
2593 .verify_decoded_call(None, &weth, U256::ZERO, &call, block, |result| {
2594 ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into)
2595 })
2596 .await,
2597 "pre-sign wrapped token probe",
2598 )?;
2599 Ok(vec![verification_decision(
2600 &balance,
2601 Some(block),
2602 Some(block),
2603 )])
2604 }
2605 TransactionAuthorization::Approve {
2606 token,
2607 router,
2608 amount,
2609 } => {
2610 let allowance_call = ERC20::allowanceCall {
2611 owner: self.wallet_address,
2612 spender: router,
2613 }
2614 .abi_encode();
2615 let allowance = required_verification(
2616 self.verification
2617 .verify_decoded_call(
2618 None,
2619 &token,
2620 U256::ZERO,
2621 &allowance_call,
2622 block,
2623 |result| {
2624 ERC20::allowanceCall::abi_decode_returns(result).map_err(Into::into)
2625 },
2626 )
2627 .await,
2628 "pre-sign router allowance",
2629 )?;
2630 anyhow::ensure!(
2631 allowance.value.is_zero() || amount.is_zero(),
2632 "Router allowance for token {token} is already {}; approve zero before setting a new nonzero allowance",
2633 allowance.value
2634 );
2635
2636 let approve_call = ERC20::approveCall {
2637 spender: router,
2638 amount,
2639 }
2640 .abi_encode();
2641 let simulation = required_verification(
2642 self.verification
2643 .verify_decoded_simulation(
2644 &self.wallet_address,
2645 &token,
2646 U256::ZERO,
2647 &approve_call,
2648 block,
2649 |result| {
2650 if result.is_empty() {
2651 Ok(true)
2652 } else {
2653 ERC20::approveCall::abi_decode_returns_validate(result)
2654 .map_err(Into::into)
2655 }
2656 },
2657 )
2658 .await,
2659 "pre-sign approval simulation",
2660 )?;
2661
2662 match &simulation.value {
2663 VerifiedSimulation::Succeeded(true) => {}
2664 VerifiedSimulation::Succeeded(false) => {
2665 anyhow::bail!("ERC-20 approve returned false for token {token}")
2666 }
2667 VerifiedSimulation::Denied => {
2668 anyhow::bail!("ERC-20 approve simulation reverted for token {token}")
2669 }
2670 }
2671 Ok(vec![
2672 verification_decision(&allowance, Some(block), Some(block)),
2673 verification_decision(&simulation, Some(block), Some(block)),
2674 ])
2675 }
2676 }
2677 }
2678
2679 async fn verify_swap_anchors_before_sign(
2680 &self,
2681 anchors: &SwapQuoteAnchors,
2682 ) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
2683 let checkpoint = required_verification(
2684 self.verification.verify_checkpoint().await,
2685 "pre-sign checkpoint reread",
2686 )?;
2687 let watermark = required_verification(
2688 self.verification
2689 .verify_block(anchors.watermark.number)
2690 .await,
2691 "pre-sign profiler watermark reread",
2692 )?;
2693 anyhow::ensure!(
2694 watermark.value == anchors.watermark,
2695 "Pool state header changed before signing"
2696 );
2697 let ancestry = required_verification(
2698 self.verification
2699 .verify_header_window(watermark.value, anchors.state.number)
2700 .await,
2701 "pre-sign profiler ancestry reread",
2702 )?;
2703
2704 if let Some(last) = ancestry.value.last() {
2705 anyhow::ensure!(
2706 *last == anchors.state,
2707 "Swap decision header changed in the pre-sign ancestry reread"
2708 );
2709 } else {
2710 anyhow::ensure!(
2711 watermark.value == anchors.state,
2712 "Empty pre-sign ancestry does not end at the swap decision header"
2713 );
2714 }
2715 let quote = match anchors.quote_kind {
2716 SwapQuoteKind::ExactInput(amount_in) => required_verification(
2717 self.verification
2718 .verify_quote_exact_input_single(
2719 &anchors.quote_contract,
2720 anchors.token_in,
2721 anchors.token_out,
2722 amount_in,
2723 anchors.fee,
2724 anchors.state.number,
2725 )
2726 .await,
2727 "pre-sign exact-input quote reread",
2728 )?,
2729 SwapQuoteKind::ExactOutput(amount_out) => required_verification(
2730 self.verification
2731 .verify_quote_exact_output_single(
2732 &anchors.quote_contract,
2733 anchors.token_in,
2734 anchors.token_out,
2735 amount_out,
2736 anchors.fee,
2737 anchors.state.number,
2738 )
2739 .await,
2740 "pre-sign exact-output quote reread",
2741 )?,
2742 };
2743 anyhow::ensure!(
2744 quote.value == anchors.quote,
2745 "Independent swap quote changed before signing"
2746 );
2747 Ok(vec![
2748 verification_decision(
2749 &checkpoint,
2750 Some(checkpoint.value.number),
2751 Some(checkpoint.value.number),
2752 ),
2753 verification_decision(
2754 &watermark,
2755 Some(anchors.watermark.number),
2756 Some(anchors.watermark.number),
2757 ),
2758 verification_decision(
2759 &ancestry,
2760 Some(anchors.watermark.number),
2761 Some(anchors.state.number),
2762 ),
2763 verification_decision(
2764 "e,
2765 Some(anchors.state.number),
2766 Some(anchors.state.number),
2767 ),
2768 ])
2769 }
2770
2771 async fn fill_and_persist(
2779 &self,
2780 prepared: &PreparedTransaction,
2781 purpose: TransactionPurpose,
2782 ) -> anyhow::Result<()> {
2783 {
2784 let mut slot = self.in_flight.lock();
2785 *slot = Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
2786 intent_id: prepared.intent_id,
2787 nonce: prepared.nonce,
2788 tx_hash: prepared.tx_hash,
2789 purpose,
2790 }));
2791 }
2792
2793 let tx_hash = prepared.tx_hash;
2794 let transaction_hash = tx_hash.to_string();
2795 let policy = self.payload_policy();
2796 let intent = self
2797 .database
2798 .get_execution_intent(prepared.intent_id)
2799 .await?;
2800 authenticate_payload_identity(
2801 &prepared.raw_tx,
2802 &intent,
2803 &transaction_hash,
2804 self.chain_id,
2805 policy,
2806 )
2807 .with_context(|| format!("Newly signed transaction {tx_hash} failed authentication"))?;
2808
2809 self.database
2810 .reserve_execution_payload_seal(&self.payload_keys)
2811 .await?;
2812 let context = payload_context_identity(
2813 &intent,
2814 &transaction_hash,
2815 self.chain_id,
2816 self.payload_keys.deployment_id(),
2817 )?;
2818 let envelope = self.payload_keys.seal(&prepared.raw_tx, &context)?;
2819 let row = self
2820 .database
2821 .add_execution_transaction_envelope(
2822 prepared.intent_id,
2823 self.chain_id,
2824 &transaction_hash,
2825 &envelope,
2826 )
2827 .await
2828 .map_err(|e| {
2829 anyhow::anyhow!(
2830 "Failed to persist transaction {tx_hash}: {e}; the in-flight slot stays occupied"
2831 )
2832 })?;
2833 let stored = open_execution_payload(
2834 &self.payload_keys,
2835 policy,
2836 &intent,
2837 &row,
2838 "initial persistence",
2839 )?;
2840 anyhow::ensure!(
2841 stored == prepared.raw_tx,
2842 "Persisted transaction {tx_hash} does not match the signed bytes"
2843 );
2844 Ok(())
2845 }
2846
2847 fn payload_policy(&self) -> PayloadPolicy {
2848 PayloadPolicy {
2849 chain_id: self.chain_id,
2850 signer: self.wallet_address,
2851 gas_limit: self.gas_limit,
2852 max_fee_per_gas: self.max_fee_per_gas_wei,
2853 }
2854 }
2855
2856 async fn authorize_rebroadcast(
2857 &self,
2858 prepared: &PreparedTransaction,
2859 intent: &ExecutionIntentRow,
2860 purpose: TransactionPurpose,
2861 ) -> anyhow::Result<ReconciliationAuthorization> {
2862 let now_unix_secs = current_unix_secs()?;
2863 let decision_header = required_verification(
2864 self.verification
2865 .verify_decision_header(now_unix_secs)
2866 .await,
2867 "rebroadcast decision header",
2868 )?;
2869 let block = decision_header.value.number;
2870 let mut decisions = vec![verification_decision(
2871 &decision_header,
2872 Some(block),
2873 Some(block),
2874 )];
2875 decisions.extend(self.verify_decision_ancestry(decision_header.value).await?);
2876 let deployment = required_verification(
2877 self.verification
2878 .verify_deployment_manifest(&self.deployment_manifest, block)
2879 .await,
2880 "rebroadcast deployment manifest",
2881 )?;
2882 decisions.push(verification_decision(&deployment, Some(block), Some(block)));
2883 let canonical_nonce = required_verification(
2884 self.verification
2885 .verify_transaction_count(&self.wallet_address, block)
2886 .await,
2887 "rebroadcast canonical nonce",
2888 )?;
2889 decisions.push(verification_decision(
2890 &canonical_nonce,
2891 Some(block),
2892 Some(block),
2893 ));
2894 let pending_nonce = required_verification(
2895 self.verification
2896 .verify_reconciliation_pending_transaction_count(
2897 &self.wallet_address,
2898 prepared.nonce,
2899 )
2900 .await,
2901 "rebroadcast pending nonce",
2902 )?;
2903 decisions.push(verification_decision(&pending_nonce, None, None));
2904 let receipt_absence = required_verification(
2905 self.verification
2906 .verify_receipt_absence(&prepared.tx_hash)
2907 .await,
2908 "rebroadcast receipt absence",
2909 )?;
2910 decisions.push(verification_decision(&receipt_absence, None, None));
2911
2912 let next_nonce = prepared
2913 .nonce
2914 .checked_add(1)
2915 .ok_or_else(|| anyhow::anyhow!("Owned signer nonce overflow"))?;
2916 anyhow::ensure!(
2917 (prepared.nonce..=next_nonce).contains(&pending_nonce.value),
2918 "Pending nonce {} is outside the owned reconciliation range {}..={next_nonce}",
2919 pending_nonce.value,
2920 prepared.nonce
2921 );
2922 anyhow::ensure!(
2923 (prepared.nonce..=next_nonce).contains(&canonical_nonce.value),
2924 "Canonical nonce {} is outside the owned reconciliation range {}..={next_nonce}",
2925 canonical_nonce.value,
2926 prepared.nonce
2927 );
2928
2929 if !receipt_absence.value {
2930 self.persist_rebroadcast_decisions(intent.id, prepared.nonce, &decisions)
2931 .await?;
2932 return Ok(ReconciliationAuthorization::Retain);
2933 }
2934
2935 if canonical_nonce.value == next_nonce {
2936 self.persist_rebroadcast_decisions(intent.id, prepared.nonce, &decisions)
2937 .await?;
2938 return Ok(ReconciliationAuthorization::ScanReplacement(
2939 decision_header.value,
2940 ));
2941 }
2942
2943 let (to, input, value) = persisted_call_fields(intent)?;
2944 let authorized = match purpose {
2945 TransactionPurpose::Wrap => {
2946 let simulation = required_verification(
2947 self.verification
2948 .verify_decoded_simulation(
2949 &self.wallet_address,
2950 &to,
2951 value,
2952 &input,
2953 block,
2954 |result| Ok(result.is_empty()),
2955 )
2956 .await,
2957 "rebroadcast wrap simulation",
2958 )?;
2959 let authorized = matches!(&simulation.value, VerifiedSimulation::Succeeded(true));
2960 decisions.push(verification_decision(&simulation, Some(block), Some(block)));
2961 authorized
2962 }
2963 TransactionPurpose::Approve => {
2964 let simulation = required_verification(
2965 self.verification
2966 .verify_decoded_simulation(
2967 &self.wallet_address,
2968 &to,
2969 value,
2970 &input,
2971 block,
2972 |result| {
2973 if result.is_empty() {
2974 Ok(true)
2975 } else {
2976 ERC20::approveCall::abi_decode_returns_validate(result)
2977 .map_err(Into::into)
2978 }
2979 },
2980 )
2981 .await,
2982 "rebroadcast approve simulation",
2983 )?;
2984 let authorized = matches!(&simulation.value, VerifiedSimulation::Succeeded(true));
2985 decisions.push(verification_decision(&simulation, Some(block), Some(block)));
2986 authorized
2987 }
2988 TransactionPurpose::Swap => {
2989 let call = UniswapV3SwapRouter::exactInputSingleCall::abi_decode(&input)
2990 .with_context(|| "persisted swap calldata is invalid")?;
2991
2992 if U256::from(decision_header.value.timestamp) > call.params.deadline {
2993 false
2994 } else {
2995 let simulation = required_verification(
2996 self.verification
2997 .verify_decoded_simulation(
2998 &self.wallet_address,
2999 &to,
3000 value,
3001 &input,
3002 block,
3003 |result| {
3004 UniswapV3SwapRouter::exactInputSingleCall::abi_decode_returns(
3005 result,
3006 )
3007 .map_err(Into::into)
3008 },
3009 )
3010 .await,
3011 "rebroadcast swap simulation",
3012 )?;
3013 let authorized = match &simulation.value {
3014 VerifiedSimulation::Succeeded(amount_out) => {
3015 *amount_out >= call.params.amountOutMinimum
3016 }
3017 VerifiedSimulation::Denied => false,
3018 };
3019 decisions.push(verification_decision(&simulation, Some(block), Some(block)));
3020 authorized
3021 }
3022 }
3023 };
3024 self.persist_rebroadcast_decisions(intent.id, prepared.nonce, &decisions)
3025 .await?;
3026 Ok(if authorized {
3027 ReconciliationAuthorization::Rebroadcast
3028 } else {
3029 ReconciliationAuthorization::Retain
3030 })
3031 }
3032
3033 async fn scan_canonical_replacement(
3034 &self,
3035 intent: &ExecutionIntentRow,
3036 nonce: u64,
3037 head: VerifiedBlockHeader,
3038 authenticated_payloads: &HashMap<B256, Vec<u8>>,
3039 ) -> anyhow::Result<Option<(B256, Vec<u8>)>> {
3040 let wallet_address = self.wallet_address.to_string();
3041 let cursor = self
3042 .database
3043 .load_execution_replacement_cursor(
3044 intent.id,
3045 self.chain_id,
3046 &wallet_address,
3047 nonce,
3048 &self.manifest_digest,
3049 )
3050 .await?;
3051 let start = cursor.as_ref().map_or(intent.created_block, |header| {
3052 header.number.saturating_add(1)
3053 });
3054 anyhow::ensure!(
3055 start <= head.number,
3056 "Canonical nonce advanced without an authenticated signer transaction in the scanned canonical range"
3057 );
3058 let scan_range = replacement_scan_range(start, head.number)?;
3059 let end = *scan_range.end();
3060 let mut decisions = Vec::new();
3061 let mut blocks = Vec::new();
3062
3063 if let Some(cursor) = cursor.as_ref() {
3064 let parent = parse_verified_header(cursor)?;
3065 let window = required_verification(
3066 self.verification
3067 .verify_replacement_window(parent, end)
3068 .await,
3069 "canonical replacement window",
3070 )?;
3071 decisions.push(verification_decision(&window, Some(start), Some(end)));
3072 blocks = window.value;
3073 } else {
3074 let start_header = required_verification(
3075 self.verification.verify_block(start).await,
3076 "canonical replacement start header",
3077 )?;
3078 decisions.push(verification_decision(
3079 &start_header,
3080 Some(start),
3081 Some(start),
3082 ));
3083 let start_block = required_verification(
3084 self.verification.verify_replacement_block(start).await,
3085 "canonical replacement start block",
3086 )?;
3087 anyhow::ensure!(
3088 VerifiedBlockHeader::from(start_block.value.clone()) == start_header.value,
3089 "Replacement block conflicts with its canonical header"
3090 );
3091 decisions.push(verification_decision(
3092 &start_block,
3093 Some(start),
3094 Some(start),
3095 ));
3096 blocks.push(start_block.value);
3097
3098 if end > start {
3099 let window = required_verification(
3100 self.verification
3101 .verify_replacement_window(start_header.value, end)
3102 .await,
3103 "canonical replacement window",
3104 )?;
3105 decisions.push(verification_decision(&window, Some(start + 1), Some(end)));
3106 blocks.extend(window.value);
3107 }
3108 }
3109
3110 let scanned_tip = blocks
3111 .last()
3112 .map(|block| VerifiedBlockHeader::from(block.clone()))
3113 .ok_or_else(|| anyhow::anyhow!("Verified replacement scan returned no blocks"))?;
3114 if end == head.number {
3115 anyhow::ensure!(
3116 scanned_tip == head,
3117 "Replacement scan tip conflicts with the verified canonical head"
3118 );
3119 }
3120 let mut candidates = blocks
3121 .iter()
3122 .flat_map(|block| block.transactions.iter())
3123 .filter(|transaction| {
3124 transaction.from == self.wallet_address && transaction.nonce == nonce
3125 });
3126 let candidate = candidates.next();
3127 anyhow::ensure!(
3128 candidates.next().is_none(),
3129 "Canonical replacement scan found duplicate signer-nonce transactions"
3130 );
3131
3132 let finalized_cursor = self
3133 .database
3134 .load_execution_verified_header(
3135 self.chain_id,
3136 &wallet_address,
3137 end,
3138 &self.manifest_digest,
3139 )
3140 .await?;
3141
3142 if let Some(cursor) = finalized_cursor.as_ref() {
3143 anyhow::ensure!(
3144 parse_verified_header(cursor)? == scanned_tip,
3145 "Replacement scan conflicts with the durable finalized header ledger"
3146 );
3147 }
3148
3149 let mut mismatch = None;
3150 let matched = candidate.and_then(|transaction| {
3151 let Some(raw_transaction) = authenticated_payloads.get(&transaction.hash).cloned()
3152 else {
3153 mismatch = Some(anyhow::anyhow!(
3154 "Canonical signer-nonce transaction {} has no authenticated retained payload",
3155 transaction.hash
3156 ));
3157 return None;
3158 };
3159
3160 if let Err(e) = validate_rpc_transaction_matches_payload(transaction, &raw_transaction)
3161 {
3162 mismatch = Some(e.context(format!(
3163 "Canonical signer-nonce transaction {} failed authenticated payload validation",
3164 transaction.hash
3165 )));
3166 return None;
3167 }
3168 Some((transaction.hash, raw_transaction))
3169 });
3170 let matched_hash = matched.as_ref().map(|(hash, _)| hash.to_string());
3171 self.database
3172 .record_execution_replacement_scan(&ExecutionReplacementScan {
3173 intent_id: intent.id,
3174 chain_id: self.chain_id,
3175 wallet_address: &wallet_address,
3176 nonce,
3177 finalized_cursor: finalized_cursor.as_ref(),
3178 matched_transaction_hash: matched_hash.as_deref(),
3179 manifest_version: &self.manifest_version,
3180 manifest_digest: &self.manifest_digest,
3181 provider_ids: &self.provider_ids,
3182 operator_ids: &self.operator_ids,
3183 failure_domain_ids: &self.failure_domain_ids,
3184 decisions: &decisions,
3185 })
3186 .await?;
3187
3188 if let Some(e) = mismatch {
3189 return Err(e);
3190 }
3191
3192 if matched.is_none() && end == head.number {
3193 anyhow::bail!(
3194 "Canonical nonce advanced without an authenticated signer transaction in the canonical range"
3195 );
3196 }
3197 Ok(matched)
3198 }
3199
3200 async fn persist_rebroadcast_decisions(
3201 &self,
3202 intent_id: i64,
3203 nonce: u64,
3204 decisions: &[ExecutionVerificationDecision],
3205 ) -> anyhow::Result<()> {
3206 let wallet_address = self.wallet_address.to_string();
3207 self.database
3208 .record_execution_verification_batch(&ExecutionVerificationBatch {
3209 intent_id,
3210 chain_id: self.chain_id,
3211 wallet_address: &wallet_address,
3212 nonce,
3213 decision_class: "rebroadcast",
3214 manifest_version: &self.manifest_version,
3215 manifest_digest: &self.manifest_digest,
3216 provider_ids: &self.provider_ids,
3217 operator_ids: &self.operator_ids,
3218 failure_domain_ids: &self.failure_domain_ids,
3219 decisions,
3220 })
3221 .await
3222 }
3223
3224 async fn broadcast(&self, prepared: &PreparedTransaction) -> anyhow::Result<BroadcastOutcome> {
3225 let tx_hash = prepared.tx_hash;
3226
3227 self.database
3228 .record_execution_status(
3229 prepared.intent_id,
3230 &tx_hash.to_string(),
3231 TransactionStatus::Broadcast,
3232 None,
3233 None,
3234 None,
3235 None,
3236 None,
3237 )
3238 .await
3239 .map_err(|e| {
3240 anyhow::anyhow!(
3241 "Failed to persist broadcast attempt for transaction {tx_hash}: {e}; the in-flight slot stays occupied"
3242 )
3243 })?;
3244
3245 match self
3246 .http_rpc_client
3247 .send_raw_transaction(&prepared.raw_tx, &tx_hash)
3248 .await
3249 {
3250 Ok(broadcast_hash) => {
3251 if broadcast_hash != tx_hash {
3252 return Ok(BroadcastOutcome::Ambiguous(format!(
3256 "Broadcast of transaction {tx_hash} returned a differing hash {broadcast_hash}; the persisted record reconciles instead of rebroadcasting"
3257 )));
3258 }
3259 Ok(BroadcastOutcome::Accepted)
3260 }
3261 Err(BroadcastError::TimeoutAfterSend) => Ok(BroadcastOutcome::Ambiguous(format!(
3262 "Broadcast of transaction {tx_hash} timed out after send; the persisted record reconciles instead of rebroadcasting"
3263 ))),
3264 Err(BroadcastError::Failed(message)) => {
3265 Ok(BroadcastOutcome::Ambiguous(format!(
3269 "Broadcast of transaction {tx_hash} failed ambiguously ({message}); the persisted record reconciles instead of rebroadcasting"
3270 )))
3271 }
3272 Err(error @ BroadcastError::Rejected { .. }) => {
3273 Ok(BroadcastOutcome::Ambiguous(format!(
3274 "Broadcast of transaction {tx_hash} was rejected ({error}); the signed hash remains occupied until canonical nonce reconciliation"
3275 )))
3276 }
3277 }
3278 }
3279
3280 async fn await_finality(
3282 &self,
3283 prepared: &PreparedTransaction,
3284 ) -> anyhow::Result<InclusionOutcome> {
3285 let tx_hash = prepared.tx_hash;
3286 let deadline = tokio::time::Instant::now() + self.receipt_timeout;
3287
3288 for attempt in 0..self.receipt_max_polls {
3289 if tokio::time::Instant::now() >= deadline {
3290 break;
3291 }
3292
3293 if attempt > 0 {
3294 tokio::time::sleep(RECEIPT_POLL_INTERVAL).await;
3295 }
3296
3297 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
3298 let receipt_result =
3299 match tokio::time::timeout(remaining, self.verification.verify_receipt(&tx_hash))
3300 .await
3301 {
3302 Ok(result) => result,
3303 Err(_) => break,
3304 };
3305
3306 match receipt_result {
3307 VerificationOutcome::Verified(verified_receipt) => {
3308 let receipt = &verified_receipt.value;
3309 let canonical_verification = required_verification(
3310 self.verification.verify_block(receipt.block_number).await,
3311 "receipt inclusion header",
3312 )?;
3313 let canonical = canonical_verification.value;
3314
3315 if canonical.hash != receipt.block_hash {
3316 continue;
3317 }
3318
3319 if let Some(mut finality) = self.receipt_is_stably_finalized(receipt).await? {
3320 finality.decisions.insert(
3321 0,
3322 verification_decision(
3323 &canonical_verification,
3324 Some(receipt.block_number),
3325 Some(receipt.block_number),
3326 ),
3327 );
3328 finality.decisions.insert(
3329 0,
3330 verification_decision(
3331 &verified_receipt,
3332 Some(receipt.block_number),
3333 Some(receipt.block_number),
3334 ),
3335 );
3336 let included = IncludedTransaction {
3337 intent_id: prepared.intent_id,
3338 nonce: prepared.nonce,
3339 tx_hash,
3340 block_number: receipt.block_number,
3341 receipt: receipt.clone(),
3342 finality,
3343 };
3344 return if receipt.status {
3345 Ok(InclusionOutcome::Finalized(included))
3346 } else {
3347 Ok(InclusionOutcome::Reverted(included))
3348 };
3349 }
3350 }
3351 VerificationOutcome::Retryable(_) => {
3352 continue;
3353 }
3354 VerificationOutcome::Disagreement(_) => {
3355 return Ok(InclusionOutcome::Pending(format!(
3356 "Receipt verification disagreed for transaction {tx_hash}; the intent stays occupied for reconciliation"
3357 )));
3358 }
3359 VerificationOutcome::Unavailable(_) => {
3360 log::warn!(
3361 "Finality poll {}/{} for transaction {tx_hash} was unavailable",
3362 attempt + 1,
3363 self.receipt_max_polls
3364 );
3365 }
3366 VerificationOutcome::LocallyInvalid(_) => {
3367 anyhow::bail!(
3368 "Receipt verification is locally invalid for transaction {tx_hash}"
3369 );
3370 }
3371 }
3372 }
3373
3374 self.database
3375 .record_execution_status(
3376 prepared.intent_id,
3377 &tx_hash.to_string(),
3378 TransactionStatus::Dropped,
3379 None,
3380 None,
3381 None,
3382 None,
3383 None,
3384 )
3385 .await?;
3386 Ok(InclusionOutcome::Pending(format!(
3387 "Timed out awaiting finality of transaction {tx_hash}; the intent stays occupied for reconciliation"
3388 )))
3389 }
3390
3391 async fn receipt_is_stably_finalized(
3392 &self,
3393 receipt: &RpcTransactionReceipt,
3394 ) -> anyhow::Result<Option<StableFinality>> {
3395 let finalized_verification = match self.verification.verify_finalized_header().await {
3396 VerificationOutcome::Verified(verified) => verified,
3397 VerificationOutcome::Retryable(_) | VerificationOutcome::Unavailable(_) => {
3398 return Ok(None);
3399 }
3400 VerificationOutcome::Disagreement(_) => {
3401 anyhow::bail!("Finalized header verification disagreed")
3402 }
3403 VerificationOutcome::LocallyInvalid(_) => {
3404 anyhow::bail!("Finalized header verification is locally invalid")
3405 }
3406 };
3407 let finalized = finalized_verification.value;
3408 if finalized.number < receipt.block_number {
3409 return Ok(None);
3410 }
3411
3412 let checkpoint_verification = required_verification(
3413 self.verification.verify_checkpoint().await,
3414 "finality checkpoint reread",
3415 )?;
3416 let checkpoint = checkpoint_verification.value;
3417 let mut decisions = vec![verification_decision(
3418 &checkpoint_verification,
3419 Some(checkpoint.number),
3420 Some(checkpoint.number),
3421 )];
3422 let position = self
3423 .database
3424 .load_execution_verification_position(
3425 self.chain_id,
3426 &self.wallet_address.to_string(),
3427 &self.manifest_version,
3428 &self.manifest_digest,
3429 )
3430 .await?
3431 .ok_or_else(|| anyhow::anyhow!("Execution verification ledger is not initialized"))?;
3432 let durable_tip = parse_verified_header(&position.finalized_tip)?;
3433 anyhow::ensure!(
3434 durable_tip.number >= checkpoint.number,
3435 "Durable finalized header tip precedes the trusted checkpoint"
3436 );
3437 let mut finalized_headers = vec![durable_tip];
3438 let durable_tip_verification = required_verification(
3439 self.verification.verify_block(durable_tip.number).await,
3440 "finality durable header tip",
3441 )?;
3442 anyhow::ensure!(
3443 durable_tip_verification.value == durable_tip,
3444 "Durable finalized header tip conflicts with independent sources"
3445 );
3446 decisions.push(verification_decision(
3447 &durable_tip_verification,
3448 Some(durable_tip.number),
3449 Some(durable_tip.number),
3450 ));
3451 anyhow::ensure!(
3452 finalized.number >= durable_tip.number,
3453 "Verified finalized height regressed below the durable finalized header tip"
3454 );
3455 let mut ancestry_cursor = durable_tip;
3456 while ancestry_cursor.number < finalized.number {
3457 let end = ancestry_cursor
3458 .number
3459 .saturating_add(4_096)
3460 .min(finalized.number);
3461 let start = ancestry_cursor.number.saturating_add(1);
3462 let ancestry_verification = required_verification(
3463 self.verification
3464 .verify_header_window(ancestry_cursor, end)
3465 .await,
3466 "finality ancestry",
3467 )?;
3468 let ancestry = &ancestry_verification.value;
3469 ancestry_cursor = *ancestry
3470 .last()
3471 .expect("nonempty finality ancestry advances the cursor");
3472 decisions.push(verification_decision(
3473 &ancestry_verification,
3474 Some(start),
3475 Some(end),
3476 ));
3477 finalized_headers.extend(ancestry.iter().copied());
3478 }
3479 anyhow::ensure!(
3480 ancestry_cursor == finalized,
3481 "Finalized header conflicts with its verified ancestry"
3482 );
3483
3484 let canonical_again_verification = required_verification(
3485 self.verification.verify_block(receipt.block_number).await,
3486 "finality inclusion header reread",
3487 )?;
3488 let canonical_again = canonical_again_verification.value;
3489 let finalized_again_verification = required_verification(
3490 self.verification.verify_block(finalized.number).await,
3491 "finalized header reread",
3492 )?;
3493 let finalized_again = finalized_again_verification.value;
3494 anyhow::ensure!(
3495 canonical_again.hash == receipt.block_hash && finalized_again == finalized,
3496 "Finality verification disagreed with the receipt or finalized header"
3497 );
3498 decisions.extend([
3499 verification_decision(
3500 &finalized_verification,
3501 Some(finalized.number),
3502 Some(finalized.number),
3503 ),
3504 verification_decision(
3505 &canonical_again_verification,
3506 Some(receipt.block_number),
3507 Some(receipt.block_number),
3508 ),
3509 verification_decision(
3510 &finalized_again_verification,
3511 Some(finalized.number),
3512 Some(finalized.number),
3513 ),
3514 ]);
3515 Ok(Some(StableFinality {
3516 decisions,
3517 inclusion_header: durable_verified_header(&canonical_again),
3518 finalized_headers: finalized_headers
3519 .iter()
3520 .map(durable_verified_header)
3521 .collect(),
3522 }))
3523 }
3524
3525 async fn commit_verified_finality(
3526 &self,
3527 included: &IncludedTransaction,
3528 status: TransactionStatus,
3529 verified_postconditions: &[ExecutionVerificationDecision],
3530 ) -> anyhow::Result<()> {
3531 anyhow::ensure!(
3532 matches!(
3533 status,
3534 TransactionStatus::Finalized | TransactionStatus::Reverted
3535 ) && included.receipt.status == (status == TransactionStatus::Finalized),
3536 "Finality status conflicts with the verified transaction receipt"
3537 );
3538 let mut decisions = included.finality.decisions.clone();
3539 decisions.extend_from_slice(verified_postconditions);
3540 let wallet_address = self.wallet_address.to_string();
3541 let transaction_hash = included.tx_hash.to_string();
3542 let block_hash = included.receipt.block_hash.to_string();
3543 let effective_gas_price = included.receipt.effective_gas_price.to_string();
3544 self.database
3545 .record_execution_finality_verified(&ExecutionFinalityTransition {
3546 intent_id: included.intent_id,
3547 chain_id: self.chain_id,
3548 wallet_address: &wallet_address,
3549 nonce: included.nonce,
3550 transaction_hash: &transaction_hash,
3551 status,
3552 block_number: included.block_number,
3553 block_hash: &block_hash,
3554 receipt_success: included.receipt.status,
3555 gas_used: included.receipt.gas_used,
3556 effective_gas_price: &effective_gas_price,
3557 manifest_version: &self.manifest_version,
3558 manifest_digest: &self.manifest_digest,
3559 provider_ids: &self.provider_ids,
3560 operator_ids: &self.operator_ids,
3561 failure_domain_ids: &self.failure_domain_ids,
3562 decisions: &decisions,
3563 finalized_headers: &included.finality.finalized_headers,
3564 })
3565 .await
3566 }
3567
3568 fn release_slot(&self) {
3569 *self.in_flight.lock() = None;
3570 }
3571}
3572
3573fn replacement_scan_range(from_block: u64, head_block: u64) -> anyhow::Result<RangeInclusive<u64>> {
3574 anyhow::ensure!(
3575 head_block >= from_block,
3576 "Canonical head {head_block} is behind execution creation block {from_block}"
3577 );
3578 let max_end = from_block.saturating_add(MAX_REPLACEMENT_SCAN_BLOCKS - 1);
3579 Ok(from_block..=head_block.min(max_end))
3580}
3581
3582fn current_unix_secs() -> anyhow::Result<u64> {
3583 SystemTime::now()
3584 .duration_since(UNIX_EPOCH)
3585 .map_err(|_| anyhow::anyhow!("Trusted host clock precedes the Unix epoch"))
3586 .map(|duration| duration.as_secs())
3587}
3588
3589fn validate_payload_operation_batch_size(batch_size: usize) -> anyhow::Result<i64> {
3590 anyhow::ensure!(
3591 (1..=MAX_PAYLOAD_OPERATION_BATCH_SIZE).contains(&batch_size),
3592 "Payload operation batch size must be between 1 and {MAX_PAYLOAD_OPERATION_BATCH_SIZE}"
3593 );
3594 Ok(i64::try_from(batch_size).expect("bounded payload batch size fits i64"))
3595}
3596
3597fn current_execution_hash(
3598 intent_id: i64,
3599 hashes: &[ExecutionTransactionHashRow],
3600) -> anyhow::Result<&ExecutionTransactionHashRow> {
3601 let mut current = hashes.iter().filter(|row| row.current);
3602 let row = current
3603 .next()
3604 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} has no current hash"))?;
3605 anyhow::ensure!(
3606 current.next().is_none(),
3607 "Execution intent {intent_id} has more than one current hash"
3608 );
3609 Ok(row)
3610}
3611
3612fn open_execution_payload(
3613 keys: &PayloadKeySet,
3614 policy: PayloadPolicy,
3615 intent: &ExecutionIntentRow,
3616 hash: &ExecutionTransactionHashRow,
3617 reason: &str,
3618) -> anyhow::Result<Vec<u8>> {
3619 anyhow::ensure!(
3620 hash.payload_expected,
3621 "Execution transaction {} has no signed payload",
3622 hash.transaction_hash
3623 );
3624 anyhow::ensure!(
3625 hash.raw_transaction.is_none(),
3626 "Protected execution transaction {} contains plaintext",
3627 hash.transaction_hash
3628 );
3629 let envelope = hash.sealed_transaction.as_deref().ok_or_else(|| {
3630 anyhow::anyhow!(
3631 "Protected execution transaction {} has no sealed payload",
3632 hash.transaction_hash
3633 )
3634 })?;
3635 let context = payload_context(intent, hash, keys.deployment_id())?;
3636 let raw_transaction = keys.unseal(envelope, &context)?;
3637 log::info!(
3638 "Unsealed execution payload for intent {} transaction {} during {reason}",
3639 intent.id,
3640 hash.transaction_hash
3641 );
3642 authenticate_retained_payload(&raw_transaction, intent, hash, keys.deployment_id())?;
3643 if retained_payload_requires_policy(intent, hash, policy)? {
3644 authenticate_payload(&raw_transaction, intent, hash, policy, keys.deployment_id())
3645 .with_context(|| {
3646 format!(
3647 "execution intent {} transaction {} violates current execution policy",
3648 intent.id, hash.transaction_hash
3649 )
3650 })?;
3651 }
3652 Ok(raw_transaction)
3653}
3654
3655fn receipt_max_polls(receipt_timeout_secs: u64) -> u32 {
3656 u32::try_from(receipt_timeout_secs.max(1)).unwrap_or(u32::MAX)
3657}
3658
3659fn receipt_timeout(receipt_timeout_secs: u64) -> Duration {
3660 Duration::from_secs(receipt_timeout_secs.clamp(1, u64::from(u32::MAX)))
3661}
3662
3663#[derive(Debug)]
3665struct SwapPlan {
3666 order: OrderAny,
3667 pool: Pool,
3668 quote_currency: Currency,
3669 instrument_id: InstrumentId,
3670 pool_address: Address,
3671 router: Address,
3672 factory: Address,
3673 weth: Address,
3674 token_in: Address,
3675 token_out: Address,
3676 fee: U24,
3677 amount_in: U256,
3678 min_amount_out: U256,
3679 slippage_bps: u32,
3680 quote_spend_ceiling: Option<QuoteSpendCeiling>,
3681 profiler_position: Option<BlockPosition>,
3682}
3683
3684#[derive(Debug, Clone)]
3685struct SwapQuoteAnchors {
3686 watermark: VerifiedBlockHeader,
3687 state: VerifiedBlockHeader,
3688 quote_contract: Address,
3689 token_in: Address,
3690 token_out: Address,
3691 fee: U24,
3692 quote_kind: SwapQuoteKind,
3693 quote: UniswapV3Quote,
3694 precondition_decisions: Vec<ExecutionVerificationDecision>,
3695}
3696
3697#[derive(Debug, Clone, Copy)]
3698enum SwapQuoteKind {
3699 ExactInput(U256),
3700 ExactOutput(U256),
3701}
3702
3703async fn execute_swap(
3712 mut plan: SwapPlan,
3713 executor: TransactionExecutor,
3714 emitter: ExecutionEventEmitter,
3715 max_quote_age_blocks: u64,
3716 deadline_seconds: u64,
3717) -> anyhow::Result<()> {
3718 let order = &plan.order;
3719
3720 if let Err(e) = executor.claim_slot(TransactionPurpose::Swap) {
3721 emitter.emit_order_denied(order, &e.to_string());
3722 return Ok(());
3723 }
3724
3725 let Some(profiler_position) = plan.profiler_position.as_ref() else {
3726 release_preparing_slot(&executor.in_flight);
3727 emitter.emit_order_denied(order, "Pool profiler has no quote provenance");
3728 return Ok(());
3729 };
3730 let mut swap_anchors = match validate_swap_quote(
3731 profiler_position,
3732 &plan,
3733 max_quote_age_blocks,
3734 &executor.verification,
3735 &executor.deployment_manifest,
3736 )
3737 .await
3738 {
3739 Ok(anchors) => anchors,
3740 Err(e) => {
3741 release_preparing_slot(&executor.in_flight);
3742 emitter.emit_order_denied(order, &e.to_string());
3743 return Ok(());
3744 }
3745 };
3746 let (amount_in, min_amount_out) = match verified_swap_amounts(&plan, swap_anchors.quote) {
3747 Ok(amounts) => amounts,
3748 Err(e) => {
3749 release_preparing_slot(&executor.in_flight);
3750 emitter.emit_order_denied(order, &e.to_string());
3751 return Ok(());
3752 }
3753 };
3754 plan.amount_in = amount_in;
3755 plan.min_amount_out = min_amount_out;
3756 let deadline = match swap_anchors.state.timestamp.checked_add(deadline_seconds) {
3757 Some(deadline) => deadline,
3758 None => {
3759 release_preparing_slot(&executor.in_flight);
3760 emitter.emit_order_denied(
3761 order,
3762 &format!(
3763 "Swap deadline overflow: anchor timestamp {} plus `deadline_seconds` {deadline_seconds} exceeds u64",
3764 swap_anchors.state.timestamp
3765 ),
3766 );
3767 return Ok(());
3768 }
3769 };
3770
3771 swap_anchors.precondition_decisions =
3772 match check_swap_preconditions(&plan, swap_anchors.state.number, &executor).await {
3773 Ok(decisions) => decisions,
3774 Err(e) => {
3775 release_preparing_slot(&executor.in_flight);
3776 emitter.emit_order_denied(order, &e.to_string());
3777 return Ok(());
3778 }
3779 };
3780
3781 let calldata = UniswapV3SwapRouter::exactInputSingleCall {
3782 params: UniswapV3SwapRouter::ExactInputSingleParams {
3783 tokenIn: plan.token_in,
3784 tokenOut: plan.token_out,
3785 fee: plan.fee,
3786 recipient: executor.wallet_address,
3787 deadline: U256::from(deadline),
3788 amountIn: plan.amount_in,
3789 amountOutMinimum: plan.min_amount_out,
3790 sqrtPriceLimitX96: U160::ZERO,
3791 },
3792 }
3793 .abi_encode();
3794
3795 let calldata = Bytes::from(calldata);
3796 let intent = ExecutionIntentInsert {
3797 chain_id: executor.chain_id,
3798 wallet_address: executor.wallet_address.to_string(),
3799 purpose: TransactionPurpose::Swap.as_str().to_string(),
3800 client_order_id: Some(order.client_order_id().to_string()),
3801 trader_id: Some(order.trader_id().to_string()),
3802 strategy_id: Some(order.strategy_id().to_string()),
3803 account_id: Some(executor.account_id.to_string()),
3804 instrument_id: Some(plan.instrument_id.to_string()),
3805 pool_address: Some(plan.pool_address.to_string()),
3806 transaction_to: plan.router.to_string(),
3807 transaction_input: hex::encode_prefixed(&calldata),
3808 transaction_value: U256::ZERO.to_string(),
3809 amount_in: Some(plan.amount_in.to_string()),
3810 created_block: swap_anchors.state.number,
3811 };
3812 let intent = match executor.database.reserve_execution_intent(&intent).await {
3813 Ok(intent) => intent,
3814 Err(e) => {
3815 release_preparing_if_reservation_not_committed(&executor.in_flight, &e);
3816 emitter.emit_order_denied(order, &e.to_string());
3817 return Ok(());
3818 }
3819 };
3820 let prepared = match executor
3821 .prepare_and_sign_swap(
3822 intent.id,
3823 intent.created_block,
3824 plan.router,
3825 U256::ZERO,
3826 calldata,
3827 &swap_anchors,
3828 )
3829 .await
3830 {
3831 Ok(prepared) => prepared,
3832 Err(e) => {
3833 if executor
3834 .database
3835 .mark_execution_intent_recoverable(intent.id)
3836 .await
3837 .is_ok()
3838 {
3839 release_preparing_slot(&executor.in_flight);
3840 }
3841 emitter.emit_order_denied(order, &e.to_string());
3842 return Ok(());
3843 }
3844 };
3845
3846 if let Err(e) = executor
3847 .fill_and_persist(&prepared, TransactionPurpose::Swap)
3848 .await
3849 {
3850 emitter.emit_order_denied(order, &e.to_string());
3851 return Ok(());
3852 }
3853
3854 let broadcast = executor.broadcast(&prepared).await?;
3855 emitter.emit_order_submitted(order);
3856 executor
3857 .database
3858 .mark_execution_event_emitted(intent.id, "acknowledgement")
3859 .await?;
3860
3861 if let BroadcastOutcome::Ambiguous(message) = broadcast {
3862 log::warn!("{message}");
3863 }
3864
3865 let trace_purpose = match plan.order.order_side() {
3866 OrderSide::Sell => "swap_sell",
3867 OrderSide::Buy => "swap_buy",
3868 };
3869
3870 match executor.await_finality(&prepared).await? {
3871 InclusionOutcome::Finalized(mut included) => {
3872 included.finality.decisions.extend(
3873 verify_finalized_transaction(
3874 &included,
3875 &intent,
3876 prepared.nonce,
3877 &prepared.raw_tx,
3878 &executor,
3879 trace_purpose,
3880 )
3881 .await?,
3882 );
3883 let fill = validate_finalized_swap_fill(&plan, &included)?;
3884 let wallet = load_verified_wallet_after_fill(&plan, &included, &executor).await?;
3885 executor
3886 .commit_verified_finality(
3887 &included,
3888 TransactionStatus::Finalized,
3889 &wallet.decisions,
3890 )
3891 .await?;
3892 complete_finalized_swap(
3893 &plan,
3894 intent.id,
3895 included.tx_hash,
3896 fill,
3897 wallet,
3898 &executor,
3899 &emitter,
3900 )
3901 .await?;
3902 executor.release_slot();
3903 Ok(())
3904 }
3905 InclusionOutcome::Reverted(mut included) => {
3906 included.finality.decisions.extend(
3907 verify_finalized_transaction(
3908 &included,
3909 &intent,
3910 prepared.nonce,
3911 &prepared.raw_tx,
3912 &executor,
3913 trace_purpose,
3914 )
3915 .await?,
3916 );
3917 executor
3918 .commit_verified_finality(&included, TransactionStatus::Reverted, &[])
3919 .await?;
3920 send_reverted_order(&emitter, order, &included)?;
3921 executor
3922 .database
3923 .mark_execution_event_emitted(intent.id, "terminal")
3924 .await?;
3925 executor.release_slot();
3926 Ok(())
3927 }
3928 InclusionOutcome::Pending(message) => anyhow::bail!(message),
3929 }
3930}
3931
3932async fn validate_swap_quote(
3933 position: &BlockPosition,
3934 plan: &SwapPlan,
3935 max_age_blocks: u64,
3936 verification: &VerificationCoordinator,
3937 manifest: &BlockchainDeploymentManifest,
3938) -> anyhow::Result<SwapQuoteAnchors> {
3939 let block_hash = position.block_hash.as_deref().ok_or_else(|| {
3940 anyhow::anyhow!(
3941 "Pool state at block {} has no ingestion-time block hash; refresh the profiler before execution",
3942 position.number
3943 )
3944 })?;
3945 let expected_block_hash = B256::from_str(block_hash)
3946 .with_context(|| format!("Invalid profiler block hash {block_hash}"))?;
3947 let now_unix_secs = current_unix_secs()?;
3948 let head = verified_value(
3949 verification.verify_decision_header(now_unix_secs).await,
3950 "swap decision header",
3951 )?;
3952 validate_quote_age(position.number, head.number, max_age_blocks)?;
3953 let canonical_block = verified_value(
3954 verification.verify_block(position.number).await,
3955 "profiler watermark header",
3956 )?;
3957 anyhow::ensure!(
3958 canonical_block.hash == expected_block_hash,
3959 "Pool state block {} changed from {} to {}; refresh the profiler before execution",
3960 position.number,
3961 expected_block_hash,
3962 canonical_block.hash
3963 );
3964
3965 let snapshot_transaction = position.transaction_index == BLOCK_SCOPED_SNAPSHOT_INDEX;
3966 let snapshot_log = position.log_index == BLOCK_SCOPED_SNAPSHOT_INDEX;
3967 anyhow::ensure!(
3968 snapshot_transaction == snapshot_log,
3969 "Pool state at block {} has an invalid partial snapshot watermark",
3970 position.number
3971 );
3972
3973 if snapshot_transaction {
3974 let snapshot_hash = B256::from_str(&position.transaction_hash)
3975 .with_context(|| "Invalid block-scoped snapshot hash")?;
3976 anyhow::ensure!(
3977 snapshot_hash == expected_block_hash,
3978 "Block-scoped snapshot hash {snapshot_hash} does not match ingestion hash {expected_block_hash}"
3979 );
3980 } else {
3981 validate_profiler_event_verified(
3982 position,
3983 expected_block_hash,
3984 plan.pool_address,
3985 &plan.pool,
3986 verification,
3987 )
3988 .await?;
3989 }
3990
3991 let ancestry = verified_value(
3992 verification
3993 .verify_header_window(canonical_block, head.number)
3994 .await,
3995 "profiler-to-decision ancestry",
3996 )?;
3997
3998 if let Some(last) = ancestry.last() {
3999 anyhow::ensure!(
4000 *last == head,
4001 "Swap decision header conflicts with profiler ancestry"
4002 );
4003 } else {
4004 anyhow::ensure!(
4005 canonical_block == head,
4006 "Empty profiler ancestry does not end at the decision header"
4007 );
4008 }
4009 verified_value(
4010 verification
4011 .verify_deployment_manifest(manifest, head.number)
4012 .await,
4013 "swap deployment manifest",
4014 )?;
4015 let quote_contract = validate_manifest_pool(plan, manifest)?;
4016 let quote_kind = match plan.order.order_side() {
4017 OrderSide::Sell => SwapQuoteKind::ExactInput(quantity_to_raw_amount(
4018 plan.order.quantity(),
4019 plan.pool.get_base_token().decimals,
4020 )?),
4021 OrderSide::Buy => SwapQuoteKind::ExactOutput(quantity_to_raw_amount(
4022 plan.order.quantity(),
4023 plan.pool.get_base_token().decimals,
4024 )?),
4025 };
4026 let quote = verified_value(
4027 verify_swap_quote(verification, quote_contract, plan, quote_kind, head.number).await,
4028 "independent swap quote",
4029 )?;
4030
4031 Ok(SwapQuoteAnchors {
4032 watermark: canonical_block,
4033 state: head,
4034 quote_contract,
4035 token_in: plan.token_in,
4036 token_out: plan.token_out,
4037 fee: plan.fee,
4038 quote_kind,
4039 quote,
4040 precondition_decisions: Vec::new(),
4041 })
4042}
4043
4044async fn verify_swap_quote(
4045 verification: &VerificationCoordinator,
4046 quote_contract: Address,
4047 plan: &SwapPlan,
4048 kind: SwapQuoteKind,
4049 block: u64,
4050) -> VerificationOutcome<UniswapV3Quote> {
4051 match kind {
4052 SwapQuoteKind::ExactInput(amount_in) => {
4053 verification
4054 .verify_quote_exact_input_single(
4055 "e_contract,
4056 plan.token_in,
4057 plan.token_out,
4058 amount_in,
4059 plan.fee,
4060 block,
4061 )
4062 .await
4063 }
4064 SwapQuoteKind::ExactOutput(amount_out) => {
4065 verification
4066 .verify_quote_exact_output_single(
4067 "e_contract,
4068 plan.token_in,
4069 plan.token_out,
4070 amount_out,
4071 plan.fee,
4072 block,
4073 )
4074 .await
4075 }
4076 }
4077}
4078
4079fn verified_swap_amounts(plan: &SwapPlan, quote: UniswapV3Quote) -> anyhow::Result<(U256, U256)> {
4080 anyhow::ensure!(
4081 !quote.amount.is_zero(),
4082 "Independent swap quote returned zero"
4083 );
4084 let base_amount =
4085 quantity_to_raw_amount(plan.order.quantity(), plan.pool.get_base_token().decimals)?;
4086 let slippage_bps = plan.slippage_bps;
4087 match plan.order.order_side() {
4088 OrderSide::Sell => Ok((
4089 base_amount,
4090 derive_min_amount_out(quote.amount, slippage_bps)?,
4091 )),
4092 OrderSide::Buy => {
4093 let ceiling = plan.quote_spend_ceiling.ok_or_else(|| {
4094 anyhow::anyhow!(
4095 "No quote spend ceiling for BUY token pair {} -> {}",
4096 plan.token_in,
4097 plan.token_out
4098 )
4099 })?;
4100 anyhow::ensure!(
4101 quote.amount <= ceiling.max_amount,
4102 "BUY quote amount {} exceeds the configured quote-spend maximum {} for {} -> {}",
4103 quote.amount,
4104 ceiling.max_amount,
4105 plan.token_in,
4106 plan.token_out
4107 );
4108 Ok((
4109 quote.amount,
4110 derive_min_amount_out(base_amount, slippage_bps)?,
4111 ))
4112 }
4113 }
4114}
4115
4116fn validate_manifest_pool(
4117 plan: &SwapPlan,
4118 manifest: &BlockchainDeploymentManifest,
4119) -> anyhow::Result<Address> {
4120 let matching = manifest
4121 .pools
4122 .iter()
4123 .filter(|pool| Address::from_str(&pool.address).ok() == Some(plan.pool_address))
4124 .collect::<Vec<_>>();
4125 anyhow::ensure!(
4126 matching.len() == 1,
4127 "Pool {} does not have exactly one deployment manifest definition",
4128 plan.pool_address
4129 );
4130 let pool = matching[0];
4131 let token0 = Address::from_str(&pool.token0)?;
4132 let token1 = Address::from_str(&pool.token1)?;
4133 let factory = Address::from_str(&pool.factory)?;
4134 let quote_contract = Address::from_str(&pool.quote_contract)?;
4135 anyhow::ensure!(
4136 token0 == plan.pool.token0.address
4137 && token1 == plan.pool.token1.address
4138 && pool.fee == plan.pool.fee.expect("validated pool fee")
4139 && factory == plan.factory,
4140 "Cached pool {} does not match its deployment manifest identity",
4141 plan.pool_address
4142 );
4143
4144 for token in [&plan.pool.token0, &plan.pool.token1] {
4145 let identities = manifest
4146 .tokens
4147 .iter()
4148 .filter(|identity| Address::from_str(&identity.address).ok() == Some(token.address))
4149 .collect::<Vec<_>>();
4150 anyhow::ensure!(
4151 identities.len() == 1,
4152 "Token {} does not have exactly one deployment manifest identity",
4153 token.address
4154 );
4155 let identity = identities[0];
4156 anyhow::ensure!(
4157 identity.name == token.name
4158 && identity.symbol == token.symbol
4159 && identity.decimals == token.decimals,
4160 "Cached token {} does not match its deployment manifest identity",
4161 token.address
4162 );
4163 let expected_role = if token.address == plan.pool.get_base_token().address {
4164 "base"
4165 } else {
4166 "quote"
4167 };
4168 anyhow::ensure!(
4169 matches!(identity.asset_role.as_str(), "both") || identity.asset_role == expected_role,
4170 "Token {} is not permitted as the pool {expected_role} asset",
4171 token.address
4172 );
4173 }
4174 Ok(quote_contract)
4175}
4176
4177async fn validate_profiler_event_verified(
4178 position: &BlockPosition,
4179 expected_block_hash: B256,
4180 pool_address: Address,
4181 pool: &Pool,
4182 verification: &VerificationCoordinator,
4183) -> anyhow::Result<()> {
4184 let transaction_hash = B256::from_str(&position.transaction_hash).with_context(|| {
4185 format!(
4186 "Invalid profiler transaction hash {}",
4187 position.transaction_hash
4188 )
4189 })?;
4190 let receipt = verified_value(
4191 verification.verify_receipt(&transaction_hash).await,
4192 "profiler watermark receipt",
4193 )?;
4194 anyhow::ensure!(
4195 receipt.status,
4196 "Profiler transaction did not execute successfully"
4197 );
4198 anyhow::ensure!(
4199 receipt.transaction_hash == transaction_hash,
4200 "Profiler receipt transaction hash does not match its ingestion watermark"
4201 );
4202 anyhow::ensure!(
4203 receipt.block_number == position.number
4204 && receipt.block_hash == expected_block_hash
4205 && receipt.transaction_index == u64::from(position.transaction_index),
4206 "Profiler receipt position does not match its ingestion watermark"
4207 );
4208 let matching_logs = receipt
4209 .logs
4210 .iter()
4211 .filter(|log| rpc_log::extract_log_index(log).ok() == Some(position.log_index))
4212 .collect::<Vec<_>>();
4213 anyhow::ensure!(
4214 matching_logs.len() == 1,
4215 "Profiler receipt contains {} logs at global index {}; expected exactly one",
4216 matching_logs.len(),
4217 position.log_index
4218 );
4219 let log = matching_logs[0];
4220 let log_transaction_hash = B256::from_str(&rpc_log::extract_transaction_hash(log)?)
4221 .with_context(|| "Invalid profiler log transaction hash")?;
4222 let log_block_hash = log
4223 .block_hash
4224 .as_deref()
4225 .ok_or_else(|| anyhow::anyhow!("Profiler log has no block hash"))?;
4226 anyhow::ensure!(
4227 !log.removed
4228 && log_transaction_hash == transaction_hash
4229 && rpc_log::extract_block_number(log)? == position.number
4230 && rpc_log::extract_transaction_index(log)? == position.transaction_index
4231 && B256::from_str(log_block_hash)? == expected_block_hash,
4232 "Profiler log position does not match its ingestion watermark"
4233 );
4234 anyhow::ensure!(
4235 rpc_log::extract_address(log)? == pool_address,
4236 "Profiler watermark log did not come from expected pool {pool_address}"
4237 );
4238 let signature = log
4239 .topics
4240 .first()
4241 .ok_or_else(|| anyhow::anyhow!("Profiler watermark log has no event signature"))?;
4242 let supported =
4243 profiler_event_signatures(pool).any(|expected| expected.eq_ignore_ascii_case(signature));
4244 anyhow::ensure!(
4245 supported,
4246 "Profiler watermark log has an unsupported event signature"
4247 );
4248 Ok(())
4249}
4250
4251fn profiler_event_signatures(pool: &Pool) -> impl Iterator<Item = &str> {
4252 [
4253 Some(pool.dex.swap_created_event.as_ref()),
4254 Some(pool.dex.mint_created_event.as_ref()),
4255 Some(pool.dex.burn_created_event.as_ref()),
4256 Some(pool.dex.collect_created_event.as_ref()),
4257 pool.dex.flash_created_event.as_deref(),
4258 pool.dex.fee_protocol_update_event.as_deref(),
4259 pool.dex.fee_protocol_collect_event.as_deref(),
4260 ]
4261 .into_iter()
4262 .flatten()
4263}
4264
4265fn validate_quote_age(
4266 profiler_block: u64,
4267 latest_block: u64,
4268 max_age_blocks: u64,
4269) -> anyhow::Result<()> {
4270 anyhow::ensure!(
4271 profiler_block <= latest_block,
4272 "Pool state at block {profiler_block} is ahead of the latest block {latest_block}; the execution RPC endpoint lags the data feed"
4273 );
4274 let quote_age = latest_block - profiler_block;
4275 anyhow::ensure!(
4276 quote_age <= max_age_blocks,
4277 "Stale quote: pool state at block {profiler_block}, latest block {latest_block}, exceeds `max_quote_age_blocks` {max_age_blocks}"
4278 );
4279 Ok(())
4280}
4281
4282fn validate_rpc_transaction_matches_payload(
4283 transaction: &RpcTransaction,
4284 raw_transaction: &[u8],
4285) -> anyhow::Result<()> {
4286 let signed = decode_signed_transaction(raw_transaction)?;
4287 anyhow::ensure!(
4288 transaction.hash == signed.hash
4289 && transaction.from == signed.signer
4290 && transaction.nonce == signed.nonce
4291 && transaction.chain_id == Some(signed.chain_id)
4292 && transaction.transaction_type == Some(2)
4293 && transaction.to == Some(signed.to)
4294 && transaction.input == signed.input
4295 && transaction.value == signed.value
4296 && transaction.gas == Some(signed.gas_limit)
4297 && transaction.max_fee_per_gas == Some(U256::from(signed.max_fee_per_gas))
4298 && transaction.max_priority_fee_per_gas
4299 == Some(U256::from(signed.max_priority_fee_per_gas)),
4300 "Verified transaction fields differ from the authenticated signed payload"
4301 );
4302 Ok(())
4303}
4304
4305async fn verify_finalized_transaction(
4306 included: &IncludedTransaction,
4307 intent: &ExecutionIntentRow,
4308 nonce: u64,
4309 raw_transaction: &[u8],
4310 executor: &TransactionExecutor,
4311 trace_purpose: &str,
4312) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4313 verify_finalized_transaction_identity(
4314 included,
4315 intent,
4316 nonce,
4317 raw_transaction,
4318 &executor.verification,
4319 executor.wallet_address,
4320 executor.chain_id,
4321 &executor.deployment_manifest,
4322 trace_purpose,
4323 )
4324 .await
4325}
4326
4327#[expect(clippy::too_many_arguments)]
4328async fn verify_finalized_transaction_identity(
4329 included: &IncludedTransaction,
4330 intent: &ExecutionIntentRow,
4331 nonce: u64,
4332 raw_transaction: &[u8],
4333 verification: &VerificationCoordinator,
4334 wallet_address: Address,
4335 chain_id: u32,
4336 deployment_manifest: &BlockchainDeploymentManifest,
4337 trace_purpose: &str,
4338) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4339 let transaction_verification = required_verification(
4340 verification.verify_transaction(&included.tx_hash).await,
4341 "finalized transaction",
4342 )?;
4343 let transaction = &transaction_verification.value;
4344 let signed = decode_signed_transaction(raw_transaction)?;
4345 let (expected_to, expected_input, expected_value) = persisted_call_fields(intent)?;
4346 anyhow::ensure!(
4347 included.receipt.transaction_hash == included.tx_hash
4348 && signed.hash == included.tx_hash
4349 && signed.signer == wallet_address
4350 && signed.chain_id == u64::from(chain_id)
4351 && signed.nonce == nonce
4352 && signed.to == expected_to
4353 && signed.input == expected_input
4354 && signed.value == expected_value,
4355 "Finalized transaction does not match the authenticated signed payload and persisted intent"
4356 );
4357 validate_rpc_transaction_matches_payload(transaction, raw_transaction)
4358 .context("finalized transaction identity mismatch")?;
4359
4360 let trace_verification = required_verification(
4361 verification.verify_call_trace(&included.tx_hash).await,
4362 "finalized call trace",
4363 )?;
4364 validate_call_trace(
4365 &trace_verification.value,
4366 &signed,
4367 included.receipt.status,
4368 trace_purpose,
4369 deployment_manifest,
4370 )?;
4371 let deployment_verification = required_verification(
4372 verification
4373 .verify_deployment_manifest(deployment_manifest, included.block_number)
4374 .await,
4375 "inclusion deployment manifest",
4376 )?;
4377
4378 Ok(vec![
4379 verification_decision(
4380 &transaction_verification,
4381 Some(included.block_number),
4382 Some(included.block_number),
4383 ),
4384 verification_decision(
4385 &trace_verification,
4386 Some(included.block_number),
4387 Some(included.block_number),
4388 ),
4389 verification_decision(
4390 &deployment_verification,
4391 Some(included.block_number),
4392 Some(included.block_number),
4393 ),
4394 ])
4395}
4396
4397fn validate_call_trace(
4398 trace: &VerifiedCallTrace,
4399 signed: &crate::execution::transaction::DecodedSignedTransaction,
4400 receipt_success: bool,
4401 purpose: &str,
4402 manifest: &BlockchainDeploymentManifest,
4403) -> anyhow::Result<()> {
4404 anyhow::ensure!(
4405 trace.call_type == RpcCallType::Call
4406 && trace.from == signed.signer
4407 && trace.to == Some(signed.to)
4408 && trace.value == signed.value
4409 && trace.input_digest == keccak256(&signed.input)
4410 && trace.success == receipt_success,
4411 "Verified call-trace root differs from the authenticated transaction"
4412 );
4413 validate_internal_calls(&trace.calls, signed.to, purpose, manifest)
4414}
4415
4416fn validate_internal_calls(
4417 calls: &[VerifiedCallTrace],
4418 caller_context: Address,
4419 purpose: &str,
4420 manifest: &BlockchainDeploymentManifest,
4421) -> anyhow::Result<()> {
4422 for call in calls {
4423 anyhow::ensure!(
4424 call.from == caller_context,
4425 "Verified call trace child has an invalid caller context"
4426 );
4427 let target = call.to.ok_or_else(|| {
4428 anyhow::anyhow!("Verified call trace contains an operation without a target")
4429 })?;
4430 let call_type = match call.call_type {
4431 RpcCallType::Call => "call",
4432 RpcCallType::Callcode => "callcode",
4433 RpcCallType::Delegatecall => "delegatecall",
4434 RpcCallType::Staticcall => "staticcall",
4435 RpcCallType::Create | RpcCallType::Create2 | RpcCallType::Selfdestruct => {
4436 anyhow::bail!("Verified call trace contains a forbidden state-changing operation")
4437 }
4438 };
4439 let permitted = manifest.call_edges.iter().any(|edge| {
4440 edge.purpose == purpose
4441 && edge.call_type.eq_ignore_ascii_case(call_type)
4442 && Address::from_str(&edge.caller).ok() == Some(call.from)
4443 && Address::from_str(&edge.target).ok() == Some(target)
4444 });
4445 anyhow::ensure!(
4446 permitted,
4447 "Verified call trace contains an unreviewed {call_type} edge {} -> {target} for {purpose}",
4448 call.from
4449 );
4450 let child_context = match call.call_type {
4451 RpcCallType::Call | RpcCallType::Staticcall => target,
4452 RpcCallType::Callcode | RpcCallType::Delegatecall => caller_context,
4453 RpcCallType::Create | RpcCallType::Create2 | RpcCallType::Selfdestruct => {
4454 unreachable!("forbidden operations return before child traversal")
4455 }
4456 };
4457 validate_internal_calls(&call.calls, child_context, purpose, manifest)?;
4458 }
4459 Ok(())
4460}
4461
4462async fn verify_wrap_balance_increase(
4463 executor: &TransactionExecutor,
4464 weth_address: &Address,
4465 amount_wei: U256,
4466 included: &IncludedTransaction,
4467) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4468 let previous_block = included.block_number.checked_sub(1).ok_or_else(|| {
4469 anyhow::anyhow!(
4470 "Included wrap transaction {} has invalid block number 0",
4471 included.tx_hash
4472 )
4473 })?;
4474 let call = ERC20::balanceOfCall {
4475 account: executor.wallet_address,
4476 }
4477 .abi_encode();
4478 let balance_before = required_verification(
4479 executor
4480 .verification
4481 .verify_decoded_call(
4482 None,
4483 weth_address,
4484 U256::ZERO,
4485 &call,
4486 previous_block,
4487 |result| ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into),
4488 )
4489 .await,
4490 "wrapped balance before finality",
4491 )
4492 .with_context(|| {
4493 format!(
4494 "failed to verify WETH balance before included transaction {} at block {previous_block}",
4495 included.tx_hash
4496 )
4497 })?;
4498 let balance_after = required_verification(
4499 executor
4500 .verification
4501 .verify_decoded_call(
4502 None,
4503 weth_address,
4504 U256::ZERO,
4505 &call,
4506 included.block_number,
4507 |result| ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into),
4508 )
4509 .await,
4510 "wrapped balance after finality",
4511 )
4512 .with_context(|| {
4513 format!(
4514 "failed to verify WETH balance after included transaction {} at block {}",
4515 included.tx_hash, included.block_number
4516 )
4517 })?;
4518 let expected_balance = balance_before
4519 .value
4520 .checked_add(amount_wei)
4521 .ok_or_else(|| {
4522 anyhow::anyhow!(
4523 "WETH balance overflow for included transaction {} at block {}",
4524 included.tx_hash,
4525 included.block_number
4526 )
4527 })?;
4528 anyhow::ensure!(
4529 balance_after.value == expected_balance,
4530 "WETH balance after transaction {} did not increase by {amount_wei}: expected {expected_balance}, was {}",
4531 included.tx_hash,
4532 balance_after.value
4533 );
4534
4535 Ok(vec![
4536 verification_decision(&balance_before, Some(previous_block), Some(previous_block)),
4537 verification_decision(
4538 &balance_after,
4539 Some(included.block_number),
4540 Some(included.block_number),
4541 ),
4542 ])
4543}
4544
4545async fn verify_approve_allowance(
4546 executor: &TransactionExecutor,
4547 token: &Address,
4548 router: &Address,
4549 amount: U256,
4550 included: &IncludedTransaction,
4551) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4552 let call = ERC20::allowanceCall {
4553 owner: executor.wallet_address,
4554 spender: *router,
4555 }
4556 .abi_encode();
4557 let allowance = required_verification(
4558 executor
4559 .verification
4560 .verify_decoded_call(
4561 None,
4562 token,
4563 U256::ZERO,
4564 &call,
4565 included.block_number,
4566 |result| ERC20::allowanceCall::abi_decode_returns(result).map_err(Into::into),
4567 )
4568 .await,
4569 "router allowance after finality",
4570 )
4571 .with_context(|| {
4572 format!(
4573 "failed to verify router allowance after included transaction {} at block {}",
4574 included.tx_hash, included.block_number
4575 )
4576 })?;
4577 anyhow::ensure!(
4578 allowance.value == amount,
4579 "Router allowance after transaction {} does not equal the requested amount {amount}: was {}",
4580 included.tx_hash,
4581 allowance.value
4582 );
4583
4584 Ok(vec![verification_decision(
4585 &allowance,
4586 Some(included.block_number),
4587 Some(included.block_number),
4588 )])
4589}
4590
4591async fn complete_finalized_swap(
4592 plan: &SwapPlan,
4593 intent_id: i64,
4594 tx_hash: B256,
4595 fill: Option<FinalizedSwapFill>,
4596 wallet: VerifiedWalletRefresh,
4597 executor: &TransactionExecutor,
4598 emitter: &ExecutionEventEmitter,
4599) -> anyhow::Result<()> {
4600 if let Some(fill) = fill {
4601 let filled = OrderFilled::new(
4602 emitter.trader_id(),
4603 plan.order.strategy_id(),
4604 plan.order.instrument_id(),
4605 plan.order.client_order_id(),
4606 fill.venue_order_id,
4607 emitter.account_id(),
4608 fill.trade_id,
4609 plan.order.order_side(),
4610 plan.order.order_type(),
4611 fill.last_qty,
4612 fill.last_px,
4613 plan.quote_currency,
4614 LiquiditySide::Taker,
4615 execution_event_id(tx_hash, b"fill"),
4616 fill.ts_event,
4617 fill.ts_event,
4618 false,
4619 None,
4620 Some(fill.commission),
4621 None,
4622 );
4623 emitter.try_send_order_event(OrderEventAny::Filled(filled))?;
4624
4625 if fill.last_qty < plan.order.quantity() {
4626 let canceled = OrderCanceled::new(
4627 emitter.trader_id(),
4628 plan.order.strategy_id(),
4629 plan.order.instrument_id(),
4630 plan.order.client_order_id(),
4631 execution_event_id(tx_hash, b"partial_cancel"),
4632 fill.ts_event,
4633 fill.ts_event,
4634 false,
4635 Some(fill.venue_order_id),
4636 Some(emitter.account_id()),
4637 None,
4638 );
4639 emitter.try_send_order_event(OrderEventAny::Canceled(canceled))?;
4640 }
4641 }
4642
4643 *executor.wallet_balance.lock() = wallet.wallet_balance;
4644 emitter.try_emit_account_state(
4645 wallet.balances,
4646 vec![],
4647 true,
4648 get_atomic_clock_realtime().get_time_ns(),
4649 None,
4650 )?;
4651 executor
4652 .database
4653 .mark_execution_event_emitted(intent_id, "fill")
4654 .await
4655}
4656
4657fn send_reverted_order(
4658 emitter: &ExecutionEventEmitter,
4659 order: &OrderAny,
4660 included: &IncludedTransaction,
4661) -> anyhow::Result<()> {
4662 let ts_event = finalized_inclusion_time(included)?;
4663 let rejected = OrderRejected::new(
4664 emitter.trader_id(),
4665 order.strategy_id(),
4666 order.instrument_id(),
4667 order.client_order_id(),
4668 emitter.account_id(),
4669 format!("Transaction {} reverted on-chain", included.tx_hash).into(),
4670 execution_event_id(included.tx_hash, b"reverted"),
4671 ts_event,
4672 ts_event,
4673 false,
4674 false,
4675 );
4676 emitter.try_send_order_event(OrderEventAny::Rejected(rejected))
4677}
4678
4679fn finalized_inclusion_time(included: &IncludedTransaction) -> anyhow::Result<UnixNanos> {
4680 let inclusion = &included.finality.inclusion_header;
4681 anyhow::ensure!(
4682 inclusion.number == included.block_number
4683 && inclusion.hash == included.receipt.block_hash.to_string(),
4684 "Verified inclusion header does not match the finalized receipt"
4685 );
4686 let timestamp = inclusion.timestamp;
4687 let nanos = timestamp
4688 .checked_mul(NANOSECONDS_IN_SECOND)
4689 .ok_or_else(|| anyhow::anyhow!("Verified inclusion timestamp exceeds nanoseconds"))?;
4690 Ok(UnixNanos::from(nanos))
4691}
4692
4693fn execution_event_id(tx_hash: B256, event: &[u8]) -> UUID4 {
4694 let mut identity = Vec::with_capacity(tx_hash.len() + event.len());
4695 identity.extend_from_slice(tx_hash.as_slice());
4696 identity.extend_from_slice(event);
4697 let digest = keccak256(identity);
4698 let mut bytes = [0u8; 16];
4699 bytes.copy_from_slice(&digest[..16]);
4700 UUID4::from_bytes(bytes)
4701}
4702
4703struct FinalizedSwapFill {
4704 venue_order_id: VenueOrderId,
4705 trade_id: TradeId,
4706 last_qty: Quantity,
4707 last_px: Price,
4708 commission: Money,
4709 ts_event: UnixNanos,
4710}
4711
4712struct VerifiedWalletRefresh {
4713 wallet_balance: WalletBalance,
4714 balances: Vec<AccountBalance>,
4715 decisions: Vec<ExecutionVerificationDecision>,
4716}
4717
4718fn validate_finalized_swap_fill(
4719 plan: &SwapPlan,
4720 included: &IncludedTransaction,
4721) -> anyhow::Result<Option<FinalizedSwapFill>> {
4722 let signature =
4723 keccak256("Swap(address,address,int256,int256,uint160,uint128,int24)").to_string();
4724 let swap_logs = included
4725 .receipt
4726 .logs
4727 .iter()
4728 .filter(|log| {
4729 !log.removed
4730 && log.topics.first().is_some_and(|topic| topic == &signature)
4731 && Address::from_str(&log.address).ok() == Some(plan.pool_address)
4732 })
4733 .collect::<Vec<_>>();
4734 anyhow::ensure!(
4735 swap_logs.len() == 1,
4736 "Finalized transaction {} emitted {} Swap logs from expected pool {}; expected exactly one",
4737 included.tx_hash,
4738 swap_logs.len(),
4739 plan.pool_address
4740 );
4741 let log = swap_logs[0];
4742 let log_transaction_hash = B256::from_str(&rpc_log::extract_transaction_hash(log)?)
4743 .with_context(|| "Invalid finalized Swap log transaction hash")?;
4744 let log_block_hash = log
4745 .block_hash
4746 .as_deref()
4747 .ok_or_else(|| anyhow::anyhow!("Finalized Swap log has no block hash"))?;
4748 anyhow::ensure!(
4749 log_transaction_hash == included.tx_hash
4750 && rpc_log::extract_block_number(log)? == included.block_number
4751 && u64::from(rpc_log::extract_transaction_index(log)?)
4752 == included.receipt.transaction_index
4753 && B256::from_str(log_block_hash)
4754 .with_context(|| "Invalid finalized Swap log block hash")?
4755 == included.receipt.block_hash,
4756 "Finalized Swap log position does not match transaction {}",
4757 included.tx_hash
4758 );
4759
4760 let dex = crate::exchanges::get_dex_extended(plan.pool.chain.name, &plan.pool.dex.name)
4761 .ok_or_else(|| {
4762 anyhow::anyhow!(
4763 "No RPC Swap decoder for {}:{}",
4764 plan.pool.chain.name,
4765 plan.pool.dex.name
4766 )
4767 })?;
4768 let event = dex.parse_swap_event_rpc(log)?;
4769 let (base_amount, quote_amount) =
4770 if plan.pool.get_base_token().address == plan.pool.token0.address {
4771 (event.amount0, event.amount1)
4772 } else {
4773 (event.amount1, event.amount0)
4774 };
4775 let last_qty = match plan.order.order_side() {
4776 OrderSide::Sell => {
4777 anyhow::ensure!(
4778 base_amount.is_positive() && base_amount.unsigned_abs() == plan.amount_in,
4779 "Finalized Swap input {base_amount} does not match the persisted amount {}",
4780 plan.amount_in
4781 );
4782 plan.order.quantity()
4783 }
4784 OrderSide::Buy => {
4785 anyhow::ensure!(
4786 quote_amount.is_positive() && quote_amount.unsigned_abs() == plan.amount_in,
4787 "Finalized Swap input {quote_amount} does not match the persisted amount {}",
4788 plan.amount_in
4789 );
4790 anyhow::ensure!(
4791 base_amount.is_negative(),
4792 "Finalized Swap base amount {base_amount} is not a BUY output"
4793 );
4794 raw_amount_to_quantity(
4795 base_amount.unsigned_abs(),
4796 plan.pool.get_base_token().decimals,
4797 )?
4798 }
4799 };
4800
4801 let block = &included.finality.inclusion_header;
4802 anyhow::ensure!(
4803 block.number == included.block_number
4804 && block.hash == included.receipt.block_hash.to_string(),
4805 "Verified inclusion header {} does not match receipt hash {}",
4806 included.block_number,
4807 included.receipt.block_hash
4808 );
4809 let timestamp_ns = block
4810 .timestamp
4811 .checked_mul(NANOSECONDS_IN_SECOND)
4812 .ok_or_else(|| anyhow::anyhow!("Finalized block timestamp overflows nanoseconds"))?;
4813 let mut swap = event.to_pool_swap(
4814 plan.pool.chain.clone(),
4815 plan.instrument_id,
4816 plan.pool.pool_identifier,
4817 UnixNanos::from(timestamp_ns),
4818 );
4819 swap.calculate_trade_info(&plan.pool.token0, &plan.pool.token1, None)?;
4820 let trade = swap
4821 .trade_info
4822 .as_ref()
4823 .ok_or_else(|| anyhow::anyhow!("Finalized Swap has no calculated trade information"))?;
4824 anyhow::ensure!(
4825 trade.order_side == plan.order.order_side(),
4826 "Finalized Swap side {} does not match {} order",
4827 trade.order_side,
4828 plan.order.order_side()
4829 );
4830 let gas_cost = included
4831 .receipt
4832 .effective_gas_price
4833 .checked_mul(U256::from(included.receipt.gas_used))
4834 .ok_or_else(|| anyhow::anyhow!("Finalized transaction gas commission overflow"))?;
4835 let commission = Money::from_u256(gas_cost, plan.pool.chain.native_currency())?;
4836 let trade_digest = keccak256(format!("{}:{}", included.tx_hash, swap.log_index));
4837 let trade_digest = trade_digest.to_string();
4838 let trade_id = TradeId::new_checked(&trade_digest[2..38])?;
4839
4840 if plan
4841 .order
4842 .trade_ids()
4843 .iter()
4844 .any(|existing| **existing == trade_id)
4845 {
4846 return Ok(None);
4847 }
4848
4849 let venue_order_id = VenueOrderId::new_checked(included.tx_hash.to_string())?;
4850 let ts_event = UnixNanos::from(timestamp_ns);
4851 let last_px = match plan.order.order_side() {
4852 OrderSide::Buy => fill_price_from_quote(last_qty, plan.amount_in, plan.quote_currency)?,
4853 _ => trade.execution_price,
4854 };
4855 Ok(Some(FinalizedSwapFill {
4856 venue_order_id,
4857 trade_id,
4858 last_qty,
4859 last_px,
4860 commission,
4861 ts_event,
4862 }))
4863}
4864
4865async fn load_verified_wallet_after_fill(
4866 plan: &SwapPlan,
4867 included: &IncludedTransaction,
4868 executor: &TransactionExecutor,
4869) -> anyhow::Result<VerifiedWalletRefresh> {
4870 let mut token_universe = executor.wallet_balance.lock().token_universe.clone();
4871 token_universe.insert(plan.pool.token0.address);
4872 token_universe.insert(plan.pool.token1.address);
4873
4874 let native_amount = required_verification(
4875 executor
4876 .verification
4877 .verify_balance(&executor.wallet_address, included.block_number)
4878 .await,
4879 "finalized native balance",
4880 )?;
4881 let native_balance = Money::from_u256(native_amount.value, plan.pool.chain.native_currency())?;
4882 let mut decisions = vec![verification_decision(
4883 &native_amount,
4884 Some(included.block_number),
4885 Some(included.block_number),
4886 )];
4887 let mut token_addresses = token_universe.iter().copied().collect::<Vec<_>>();
4888 token_addresses.sort_unstable();
4889 let mut token_balances = Vec::with_capacity(token_addresses.len());
4890 for address in token_addresses {
4891 let identities = executor
4892 .deployment_manifest
4893 .tokens
4894 .iter()
4895 .filter(|identity| Address::from_str(&identity.address).ok() == Some(address))
4896 .collect::<Vec<_>>();
4897 anyhow::ensure!(
4898 identities.len() == 1,
4899 "Wallet token {address} does not have exactly one deployment manifest identity"
4900 );
4901 let identity = identities[0];
4902 let token = Token::new(
4903 plan.pool.chain.clone(),
4904 address,
4905 identity.name.clone(),
4906 identity.symbol.clone(),
4907 identity.decimals,
4908 );
4909 let call = ERC20::balanceOfCall {
4910 account: executor.wallet_address,
4911 }
4912 .abi_encode();
4913 let amount = required_verification(
4914 executor
4915 .verification
4916 .verify_decoded_call(
4917 None,
4918 &address,
4919 U256::ZERO,
4920 &call,
4921 included.block_number,
4922 |result| ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into),
4923 )
4924 .await,
4925 "finalized token balance",
4926 )?;
4927 decisions.push(verification_decision(
4928 &amount,
4929 Some(included.block_number),
4930 Some(included.block_number),
4931 ));
4932 token_balances.push(TokenBalance::new(amount.value, token));
4933 }
4934
4935 let mut wallet_balance = WalletBalance::new(token_universe);
4936 let balances = wallet_balance.replace_balances(native_balance, token_balances)?;
4937 Ok(VerifiedWalletRefresh {
4938 wallet_balance,
4939 balances,
4940 decisions,
4941 })
4942}
4943
4944async fn verify_connect_capabilities(
4945 verification: &VerificationCoordinator,
4946 manifest: &BlockchainDeploymentManifest,
4947 wallet: Address,
4948 weth: Address,
4949 block: u64,
4950) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
4951 let contract = manifest
4952 .contracts
4953 .first()
4954 .ok_or_else(|| anyhow::anyhow!("Deployment manifest has no capability probe target"))?;
4955 let contract_address = Address::from_str(&contract.address)
4956 .with_context(|| "Deployment manifest capability probe target is invalid")?;
4957 let storage = required_verification(
4958 verification
4959 .verify_storage(&contract_address, &B256::ZERO, block)
4960 .await,
4961 "Blockchain explicit-height storage capability",
4962 )?;
4963
4964 let balance_call = ERC20::balanceOfCall { account: wallet }.abi_encode();
4965 let gas = required_verification(
4966 verification
4967 .verify_gas_estimate(&wallet, &weth, U256::ZERO, &balance_call, block)
4968 .await,
4969 "Blockchain explicit-height gas capability",
4970 )?;
4971
4972 let mut decisions = vec![
4973 verification_decision(&storage, Some(block), Some(block)),
4974 verification_decision(&gas, Some(block), Some(block)),
4975 ];
4976
4977 for pool in &manifest.pools {
4978 let quote_contract = Address::from_str(&pool.quote_contract)
4979 .with_context(|| "Deployment manifest quote capability target is invalid")?;
4980 let token_in = Address::from_str(&pool.token0)
4981 .with_context(|| "Deployment manifest quote input token is invalid")?;
4982 let token_out = Address::from_str(&pool.token1)
4983 .with_context(|| "Deployment manifest quote output token is invalid")?;
4984 let fee = U24::try_from(pool.fee)
4985 .map_err(|_| anyhow::anyhow!("Deployment manifest pool fee is invalid"))?;
4986 let quote = required_verification(
4987 verification
4988 .verify_quote_exact_input_single(
4989 "e_contract,
4990 token_in,
4991 token_out,
4992 U256::from(1u64),
4993 fee,
4994 block,
4995 )
4996 .await,
4997 "Blockchain explicit-height quote capability",
4998 )?;
4999 decisions.push(verification_decision("e, Some(block), Some(block)));
5000 }
5001
5002 let trace = required_verification(
5003 verification.verify_call_trace_capability().await,
5004 "Blockchain call trace capability",
5005 )?;
5006 decisions.push(verification_decision(&trace, None, None));
5007 Ok(decisions)
5008}
5009
5010async fn check_swap_preconditions(
5015 plan: &SwapPlan,
5016 block: u64,
5017 executor: &TransactionExecutor,
5018) -> anyhow::Result<Vec<ExecutionVerificationDecision>> {
5019 let mut decisions = Vec::new();
5020 let factory_call = UniswapV3RouterState::factoryCall.abi_encode();
5021 let router_factory = required_verification(
5022 executor
5023 .verification
5024 .verify_decoded_call(
5025 None,
5026 &plan.router,
5027 U256::ZERO,
5028 &factory_call,
5029 block,
5030 |result| {
5031 UniswapV3RouterState::factoryCall::abi_decode_returns(result)
5032 .map_err(Into::into)
5033 },
5034 )
5035 .await,
5036 "swap router factory",
5037 )?;
5038 anyhow::ensure!(
5039 router_factory.value == plan.factory,
5040 "Router {} reports an unexpected factory",
5041 plan.router,
5042 );
5043 decisions.push(verification_decision(
5044 &router_factory,
5045 Some(block),
5046 Some(block),
5047 ));
5048
5049 let weth_call = UniswapV3RouterState::WETH9Call.abi_encode();
5050 let router_weth = required_verification(
5051 executor
5052 .verification
5053 .verify_decoded_call(
5054 None,
5055 &plan.router,
5056 U256::ZERO,
5057 &weth_call,
5058 block,
5059 |result| {
5060 UniswapV3RouterState::WETH9Call::abi_decode_returns(result).map_err(Into::into)
5061 },
5062 )
5063 .await,
5064 "swap router wrapped native",
5065 )?;
5066 anyhow::ensure!(
5067 router_weth.value == plan.weth,
5068 "Router {} reports an unexpected wrapped native contract",
5069 plan.router,
5070 );
5071 decisions.push(verification_decision(
5072 &router_weth,
5073 Some(block),
5074 Some(block),
5075 ));
5076
5077 let pool_call = UniswapV3Factory::getPoolCall {
5078 tokenA: plan.token_in,
5079 tokenB: plan.token_out,
5080 fee: plan.fee,
5081 }
5082 .abi_encode();
5083 let registered_pool = required_verification(
5084 executor
5085 .verification
5086 .verify_decoded_call(
5087 None,
5088 &plan.factory,
5089 U256::ZERO,
5090 &pool_call,
5091 block,
5092 |result| {
5093 UniswapV3Factory::getPoolCall::abi_decode_returns(result).map_err(Into::into)
5094 },
5095 )
5096 .await,
5097 "swap factory pool",
5098 )?;
5099 anyhow::ensure!(
5100 registered_pool.value == plan.pool_address,
5101 "Factory resolves an unexpected pool for the swap token pair and fee"
5102 );
5103 decisions.push(verification_decision(
5104 ®istered_pool,
5105 Some(block),
5106 Some(block),
5107 ));
5108
5109 for token in [&plan.pool.token0, &plan.pool.token1] {
5110 let decimals_call = ERC20::decimalsCall.abi_encode();
5111 let decimals = required_verification(
5112 executor
5113 .verification
5114 .verify_decoded_call(
5115 None,
5116 &token.address,
5117 U256::ZERO,
5118 &decimals_call,
5119 block,
5120 |result| ERC20::decimalsCall::abi_decode_returns(result).map_err(Into::into),
5121 )
5122 .await,
5123 "swap token decimals",
5124 )?;
5125 anyhow::ensure!(
5126 decimals.value == token.decimals,
5127 "Token {} reports unexpected decimals",
5128 token.address,
5129 );
5130 decisions.push(verification_decision(&decimals, Some(block), Some(block)));
5131 }
5132
5133 let allowance_call = ERC20::allowanceCall {
5134 owner: executor.wallet_address,
5135 spender: plan.router,
5136 }
5137 .abi_encode();
5138 let allowance = required_verification(
5139 executor
5140 .verification
5141 .verify_decoded_call(
5142 None,
5143 &plan.token_in,
5144 U256::ZERO,
5145 &allowance_call,
5146 block,
5147 |result| ERC20::allowanceCall::abi_decode_returns(result).map_err(Into::into),
5148 )
5149 .await,
5150 "swap input allowance",
5151 )?;
5152
5153 if allowance.value < plan.amount_in {
5154 anyhow::bail!(
5155 "Router allowance {} is below the swap amount {} for input token {}; approve the router explicitly before submitting",
5156 allowance.value,
5157 plan.amount_in,
5158 plan.token_in
5159 );
5160 }
5161 decisions.push(verification_decision(&allowance, Some(block), Some(block)));
5162
5163 let balance_call = ERC20::balanceOfCall {
5164 account: executor.wallet_address,
5165 }
5166 .abi_encode();
5167 let balance = required_verification(
5168 executor
5169 .verification
5170 .verify_decoded_call(
5171 None,
5172 &plan.token_in,
5173 U256::ZERO,
5174 &balance_call,
5175 block,
5176 |result| ERC20::balanceOfCall::abi_decode_returns(result).map_err(Into::into),
5177 )
5178 .await,
5179 "swap input balance",
5180 )?;
5181
5182 if balance.value < plan.amount_in {
5183 anyhow::bail!(
5184 "Input token {} balance {} is below the swap amount {}",
5185 plan.token_in,
5186 balance.value,
5187 plan.amount_in
5188 );
5189 }
5190 decisions.push(verification_decision(&balance, Some(block), Some(block)));
5191
5192 Ok(decisions)
5193}
5194
5195fn quantity_to_raw_amount(quantity: Quantity, decimals: u8) -> anyhow::Result<U256> {
5202 if quantity.is_zero() {
5203 anyhow::bail!("Order quantity must be positive");
5204 }
5205
5206 let raw = U256::from(quantity.raw());
5207 let raw_precision = quantity.precision.max(FIXED_PRECISION);
5208 if decimals >= raw_precision {
5209 let scale = U256::from(10u64)
5210 .checked_pow(U256::from(decimals - raw_precision))
5211 .ok_or_else(|| anyhow::anyhow!("Order amount scaling overflow"))?;
5212 raw.checked_mul(scale).ok_or_else(|| {
5213 anyhow::anyhow!("Order amount overflow scaling quantity to raw token units")
5214 })
5215 } else {
5216 let divisor = U256::from(10u64)
5217 .checked_pow(U256::from(raw_precision - decimals))
5218 .ok_or_else(|| anyhow::anyhow!("Order amount scaling overflow"))?;
5219 if !(raw % divisor).is_zero() {
5220 anyhow::bail!(
5221 "Order quantity {quantity} is not exactly representable in {decimals} base token decimals"
5222 );
5223 }
5224 Ok(raw / divisor)
5225 }
5226}
5227
5228fn swap_token_pair(
5229 side: OrderSide,
5230 base: Address,
5231 quote: Address,
5232) -> anyhow::Result<(Address, Address)> {
5233 match side {
5234 OrderSide::Sell => Ok((base, quote)),
5235 OrderSide::Buy => Ok((quote, base)),
5236 }
5237}
5238
5239fn fill_price_from_quote(
5240 last_qty: Quantity,
5241 quote_amount: U256,
5242 quote_currency: Currency,
5243) -> anyhow::Result<Price> {
5244 let quote = Money::from_u256(quote_amount, quote_currency)?;
5245 Price::from_decimal_dp(quote.as_decimal() / last_qty.as_decimal(), FIXED_PRECISION)
5246 .map_err(anyhow::Error::from)
5247}
5248
5249fn raw_amount_to_quantity(amount: U256, decimals: u8) -> anyhow::Result<Quantity> {
5250 if amount.is_zero() {
5251 anyhow::bail!("Executed amount must be positive");
5252 }
5253 let quantity = if decimals >= FIXED_PRECISION {
5254 let scale = U256::from(10u64)
5255 .checked_pow(U256::from(decimals - FIXED_PRECISION))
5256 .ok_or_else(|| anyhow::anyhow!("Executed amount scaling overflow"))?;
5257 Quantity::from_u256(amount / scale, FIXED_PRECISION).map_err(anyhow::Error::from)?
5258 } else {
5259 Quantity::from_u256(amount, decimals).map_err(anyhow::Error::from)?
5260 };
5261
5262 if quantity.is_zero() {
5263 anyhow::bail!(
5264 "Executed amount {amount} is below representable quantity precision {FIXED_PRECISION}"
5265 );
5266 }
5267 Ok(quantity)
5268}
5269
5270fn exact_output_amount(quote: &SwapQuote, zero_for_one: bool) -> anyhow::Result<U256> {
5271 let amount = if zero_for_one {
5272 quote.amount1
5273 } else {
5274 quote.amount0
5275 };
5276
5277 if !amount.is_negative() {
5278 anyhow::bail!("Swap quote output amount {amount} is not a positive output");
5279 }
5280 Ok(amount.unsigned_abs())
5281}
5282
5283fn derive_min_amount_out(quoted_amount_out: U256, slippage_bps: u32) -> anyhow::Result<U256> {
5287 if slippage_bps >= BPS_DENOMINATOR {
5288 anyhow::bail!("Slippage {slippage_bps} bps must be below {BPS_DENOMINATOR}");
5289 }
5290 let min_amount_out = quoted_amount_out
5291 .checked_mul(U256::from(BPS_DENOMINATOR - slippage_bps))
5292 .and_then(|scaled| scaled.checked_div(U256::from(BPS_DENOMINATOR)))
5293 .ok_or_else(|| anyhow::anyhow!("Minimum output derivation overflow"))?;
5294 if min_amount_out.is_zero() {
5295 anyhow::bail!(
5296 "Derived minimum output is zero for quoted output {quoted_amount_out} at {slippage_bps} bps slippage"
5297 );
5298 }
5299 Ok(min_amount_out)
5300}
5301
5302impl BlockchainExecutionClient {
5303 async fn build_execution_verification_migration(
5304 &self,
5305 snapshot: ExecutionVerificationMigrationSnapshot,
5306 finalized: VerifiedBlockHeader,
5307 finalized_headers: &[VerifiedBlockHeader],
5308 nonce_verification: &Verified<u64>,
5309 ) -> anyhow::Result<ExecutionVerificationMigration> {
5310 let next_canonical_nonce = nonce_verification.value;
5311 let mut hashes_by_intent: HashMap<i64, Vec<&ExecutionTransactionHashRow>> = HashMap::new();
5312 for hash in &snapshot.hashes {
5313 hashes_by_intent
5314 .entry(hash.intent_id)
5315 .or_default()
5316 .push(hash);
5317 }
5318 let active_count = snapshot
5319 .intents
5320 .iter()
5321 .filter(|intent| intent.active)
5322 .count();
5323 anyhow::ensure!(
5324 active_count <= 1,
5325 "Retained execution history has multiple active signer owners"
5326 );
5327
5328 let mut nonce_owners = HashMap::new();
5329 let mut records = Vec::with_capacity(snapshot.intents.len());
5330 let finalized_headers = finalized_headers
5331 .iter()
5332 .map(durable_verified_header)
5333 .collect::<Vec<_>>();
5334
5335 for intent in &snapshot.intents {
5336 anyhow::ensure!(
5337 intent.chain_id == self.chain.chain_id
5338 && intent.wallet_address == self.config.wallet_address,
5339 "Retained execution intent belongs to another signer"
5340 );
5341 let purpose = TransactionPurpose::parse(&intent.purpose).ok_or_else(|| {
5342 anyhow::anyhow!("Retained execution intent has an unsupported purpose")
5343 })?;
5344 let hashes = hashes_by_intent
5345 .get(&intent.id)
5346 .map(Vec::as_slice)
5347 .unwrap_or_default();
5348 let current = hashes
5349 .iter()
5350 .copied()
5351 .filter(|hash| hash.current)
5352 .collect::<Vec<_>>();
5353 anyhow::ensure!(
5354 current.len() <= 1,
5355 "Retained execution intent {} has multiple current hashes",
5356 intent.id
5357 );
5358 let current = current.first().copied();
5359 let mut authenticated = HashMap::new();
5360
5361 for hash in hashes {
5362 if hash.payload_expected {
5363 let raw = open_execution_payload(
5364 self.payload_keys
5365 .as_deref()
5366 .expect("Postgres execution requires payload keys"),
5367 self.payload_policy(),
5368 intent,
5369 hash,
5370 "verification migration",
5371 )?;
5372 authenticated.insert(hash.id, raw);
5373 } else {
5374 anyhow::ensure!(
5375 hash.raw_transaction.is_none() && hash.sealed_transaction.is_none(),
5376 "Unowned replacement hash retains signed bytes"
5377 );
5378 }
5379 }
5380
5381 if let Some(nonce) = intent.nonce {
5382 anyhow::ensure!(
5383 nonce_owners.insert(nonce, intent.id).is_none(),
5384 "Retained execution history has duplicate signer nonce ownership"
5385 );
5386 }
5387
5388 let base_decision = verification_decision(
5389 nonce_verification,
5390 Some(finalized.number),
5391 Some(finalized.number),
5392 );
5393
5394 if !intent.active {
5395 if matches!(intent.status.as_str(), "finalized" | "reverted") {
5396 let expected_marker =
5397 if purpose == TransactionPurpose::Swap && intent.status == "finalized" {
5398 intent.fill_emitted
5399 } else {
5400 intent.terminal_emitted
5401 };
5402 anyhow::ensure!(
5403 expected_marker,
5404 "Released terminal intent {} has no durable event marker",
5405 intent.id
5406 );
5407 } else {
5408 anyhow::ensure!(
5409 matches!(intent.status.as_str(), "dropped" | "recoverable")
5410 && authenticated.is_empty(),
5411 "Released nonterminal intent {} retains signed ownership",
5412 intent.id
5413 );
5414 records.push(ExecutionVerificationMigrationRecord {
5415 intent_id: intent.id,
5416 nonce: intent.nonce,
5417 transaction_hash: None,
5418 terminal_status: None,
5419 block_number: None,
5420 block_hash: None,
5421 receipt_success: None,
5422 gas_used: None,
5423 effective_gas_price: None,
5424 recover_prepared: false,
5425 decisions: vec![base_decision],
5426 });
5427 continue;
5428 }
5429 }
5430
5431 if intent.active && intent.nonce.is_none() {
5432 anyhow::ensure!(
5433 intent.status == "prepared" && hashes.is_empty(),
5434 "Unassigned active intent {} is not an unsigned preparation",
5435 intent.id
5436 );
5437 records.push(ExecutionVerificationMigrationRecord {
5438 intent_id: intent.id,
5439 nonce: None,
5440 transaction_hash: None,
5441 terminal_status: None,
5442 block_number: None,
5443 block_hash: None,
5444 receipt_success: None,
5445 gas_used: None,
5446 effective_gas_price: None,
5447 recover_prepared: true,
5448 decisions: vec![base_decision],
5449 });
5450 continue;
5451 }
5452
5453 let nonce = intent.nonce.ok_or_else(|| {
5454 anyhow::anyhow!("Retained signed intent {} has no nonce", intent.id)
5455 })?;
5456 let current = current.ok_or_else(|| {
5457 anyhow::anyhow!("Retained signed intent {} has no current hash", intent.id)
5458 })?;
5459 let raw_transaction = authenticated.get(¤t.id).ok_or_else(|| {
5460 anyhow::anyhow!(
5461 "Retained signed intent {} has no authenticated current payload",
5462 intent.id
5463 )
5464 })?;
5465
5466 if intent.active && nonce == next_canonical_nonce {
5467 anyhow::ensure!(
5468 !matches!(intent.status.as_str(), "finalized" | "reverted"),
5469 "Active terminal intent conflicts with the canonical nonce ledger"
5470 );
5471 records.push(ExecutionVerificationMigrationRecord {
5472 intent_id: intent.id,
5473 nonce: Some(nonce),
5474 transaction_hash: Some(current.transaction_hash.clone()),
5475 terminal_status: None,
5476 block_number: None,
5477 block_hash: None,
5478 receipt_success: None,
5479 gas_used: None,
5480 effective_gas_price: None,
5481 recover_prepared: false,
5482 decisions: vec![base_decision],
5483 });
5484 continue;
5485 }
5486 anyhow::ensure!(
5487 nonce < next_canonical_nonce,
5488 "Retained active nonce {nonce} is above canonical nonce {next_canonical_nonce}"
5489 );
5490
5491 let receipt_verification = required_verification(
5492 self.verification
5493 .verify_receipt(&B256::from_str(¤t.transaction_hash).with_context(
5494 || {
5495 format!(
5496 "Retained transaction hash {} is invalid",
5497 current.transaction_hash
5498 )
5499 },
5500 )?)
5501 .await,
5502 "migration receipt",
5503 )?;
5504 let receipt = receipt_verification.value.clone();
5505 anyhow::ensure!(
5506 receipt.block_number <= finalized.number,
5507 "Retained terminal receipt is above the verified finalized boundary"
5508 );
5509 let inclusion_verification = required_verification(
5510 self.verification.verify_block(receipt.block_number).await,
5511 "migration inclusion header",
5512 )?;
5513 anyhow::ensure!(
5514 inclusion_verification.value.hash == receipt.block_hash
5515 && finalized_headers.iter().any(|header| {
5516 header.number == receipt.block_number
5517 && header.hash == receipt.block_hash.to_string()
5518 }),
5519 "Retained terminal receipt is not on the verified finalized ancestry"
5520 );
5521 let tx_hash = B256::from_str(¤t.transaction_hash)
5522 .context("Retained transaction hash is invalid")?;
5523 let included = IncludedTransaction {
5524 intent_id: intent.id,
5525 nonce,
5526 tx_hash,
5527 block_number: receipt.block_number,
5528 receipt: receipt.clone(),
5529 finality: StableFinality {
5530 decisions: Vec::new(),
5531 inclusion_header: durable_verified_header(&inclusion_verification.value),
5532 finalized_headers: finalized_headers.clone(),
5533 },
5534 };
5535 let trace_purpose = match purpose {
5536 TransactionPurpose::Wrap => "wrap",
5537 TransactionPurpose::Approve => "approve",
5538 TransactionPurpose::Swap => {
5539 match self.restore_swap_plan(intent)?.order.order_side() {
5540 OrderSide::Sell => "swap_sell",
5541 OrderSide::Buy => "swap_buy",
5542 }
5543 }
5544 };
5545 let mut decisions = vec![
5546 verification_decision(
5547 &receipt_verification,
5548 Some(receipt.block_number),
5549 Some(receipt.block_number),
5550 ),
5551 verification_decision(
5552 &inclusion_verification,
5553 Some(receipt.block_number),
5554 Some(receipt.block_number),
5555 ),
5556 ];
5557 decisions.extend(
5558 verify_finalized_transaction_identity(
5559 &included,
5560 intent,
5561 nonce,
5562 raw_transaction,
5563 &self.verification,
5564 self.wallet_address,
5565 self.chain.chain_id,
5566 &self
5567 .config
5568 .verification
5569 .as_ref()
5570 .expect("verification config validated")
5571 .deployment_manifest,
5572 trace_purpose,
5573 )
5574 .await?,
5575 );
5576 let terminal_status = if receipt.status {
5577 TransactionStatus::Finalized
5578 } else {
5579 TransactionStatus::Reverted
5580 };
5581
5582 if !intent.active {
5583 anyhow::ensure!(
5584 intent.status == terminal_status.as_str(),
5585 "Released terminal intent status conflicts with its verified receipt"
5586 );
5587 }
5588 records.push(ExecutionVerificationMigrationRecord {
5589 intent_id: intent.id,
5590 nonce: Some(nonce),
5591 transaction_hash: Some(current.transaction_hash.clone()),
5592 terminal_status: Some(terminal_status),
5593 block_number: Some(receipt.block_number),
5594 block_hash: Some(receipt.block_hash.to_string()),
5595 receipt_success: Some(receipt.status),
5596 gas_used: Some(receipt.gas_used),
5597 effective_gas_price: Some(receipt.effective_gas_price.to_string()),
5598 recover_prepared: false,
5599 decisions,
5600 });
5601 }
5602
5603 Ok(ExecutionVerificationMigration { snapshot, records })
5604 }
5605}
5606
5607#[async_trait(?Send)]
5608impl ExecutionClient for BlockchainExecutionClient {
5609 fn is_connected(&self) -> bool {
5610 self.core.is_connected()
5611 }
5612
5613 fn client_id(&self) -> ClientId {
5614 self.core.client_id
5615 }
5616
5617 fn account_id(&self) -> AccountId {
5618 self.core.account_id
5619 }
5620
5621 fn venue(&self) -> Venue {
5622 self.core.venue
5623 }
5624
5625 fn handles_order_venue(&self, venue: Venue) -> bool {
5626 venue.parse_dex().is_ok_and(|(blockchain, dex_type)| {
5627 blockchain == self.chain.name && dex_type == DexType::UniswapV3
5628 })
5629 }
5630
5631 fn oms_type(&self) -> OmsType {
5632 self.core.oms_type
5633 }
5634
5635 fn get_account(&self) -> Option<AccountAny> {
5636 self.core.cache().account_owned(&self.core.account_id)
5637 }
5638
5639 fn generate_account_state(
5640 &self,
5641 balances: Vec<AccountBalance>,
5642 margins: Vec<MarginBalance>,
5643 reported: bool,
5644 ts_event: UnixNanos,
5645 info: Option<Params>,
5646 ) -> anyhow::Result<()> {
5647 self.emitter
5648 .try_emit_account_state(balances, margins, reported, ts_event, info)
5649 }
5650
5651 fn start(&mut self) -> anyhow::Result<()> {
5652 if self.core.is_started() {
5653 return Ok(());
5654 }
5655
5656 self.emitter.set_sender(get_exec_event_sender());
5657 self.core.set_started();
5658 log::info!(
5659 "Started: client_id={}, account_id={}",
5660 self.core.client_id,
5661 self.core.account_id
5662 );
5663 Ok(())
5664 }
5665
5666 fn stop(&mut self) -> anyhow::Result<()> {
5667 if self.core.is_stopped() {
5668 return Ok(());
5669 }
5670
5671 self.pending_tasks.begin_shutdown();
5672 self.signer = None;
5673 self.core.set_stopped();
5674 self.core.set_disconnected();
5675 log::info!("Stopped: client_id={}", self.core.client_id);
5676 Ok(())
5677 }
5678
5679 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
5680 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
5681
5682 if order.is_closed() {
5683 log::warn!("Cannot submit closed order {}", order.client_order_id());
5684 return Ok(());
5685 }
5686
5687 if let Err(reason) = validate_order(&order) {
5688 self.emitter.emit_order_denied(&order, &reason.to_string());
5689 return Ok(());
5690 }
5691
5692 if !self.pending_tasks.is_open() {
5693 self.emitter
5694 .emit_order_denied(&order, "Blockchain execution client is shutting down");
5695 return Ok(());
5696 }
5697
5698 let plan = match self.prepare_swap(&cmd, &order) {
5699 Ok(plan) => plan,
5700 Err(e) => {
5701 self.emitter.emit_order_denied(&order, &e.to_string());
5702 return Ok(());
5703 }
5704 };
5705
5706 let executor = match self.transaction_executor() {
5707 Ok(executor) => executor,
5708 Err(e) => {
5709 self.emitter.emit_order_denied(&order, &e.to_string());
5710 return Ok(());
5711 }
5712 };
5713
5714 let emitter = self.emitter.clone();
5715 let max_quote_age_blocks = self.transaction_limits.max_quote_age_blocks;
5716 let deadline_seconds = self.transaction_limits.deadline_seconds;
5717 let client_order_id = order.client_order_id();
5718
5719 let future = async move {
5720 if let Err(e) = execute_swap(
5721 plan,
5722 executor,
5723 emitter,
5724 max_quote_age_blocks,
5725 deadline_seconds,
5726 )
5727 .await
5728 {
5729 log::warn!("Swap execution for order {client_order_id} failed: {e:?}");
5730 }
5731 };
5732
5733 if let Err(e) = self.pending_tasks.spawn(future) {
5734 release_preparing_slot(&self.in_flight);
5735 log::warn!("Skipping blockchain swap after shutdown began: {e}");
5736 }
5737
5738 Ok(())
5739 }
5740
5741 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
5742 let orders = self
5743 .core
5744 .cache()
5745 .orders_for_ids(&cmd.order_list.client_order_ids, &cmd);
5746
5747 for order in &orders {
5748 if order.is_closed() {
5749 log::warn!("Cannot submit closed order {}", order.client_order_id());
5750 continue;
5751 }
5752
5753 self.emitter
5754 .emit_order_denied(order, ORDER_LIST_UNSUPPORTED);
5755 }
5756
5757 Ok(())
5758 }
5759
5760 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
5761 let Ok(order) = self.core.cache().try_order_owned(&cmd.client_order_id) else {
5762 log::warn!("Cannot modify unknown order {}", cmd.client_order_id);
5763 return Ok(());
5764 };
5765
5766 self.emitter.emit_order_modify_rejected(
5767 &order,
5768 cmd.venue_order_id,
5769 ORDER_MODIFY_UNSUPPORTED,
5770 get_atomic_clock_realtime().get_time_ns(),
5771 );
5772 Ok(())
5773 }
5774
5775 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
5776 let Ok(order) = self.core.cache().try_order_owned(&cmd.client_order_id) else {
5777 log::warn!("Cannot cancel unknown order {}", cmd.client_order_id);
5778 return Ok(());
5779 };
5780
5781 self.emitter.emit_order_cancel_rejected(
5782 &order,
5783 cmd.venue_order_id,
5784 ORDER_CANCEL_UNSUPPORTED,
5785 get_atomic_clock_realtime().get_time_ns(),
5786 );
5787 Ok(())
5788 }
5789
5790 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
5791 log::warn!(
5792 "Cancel-all for {} is not supported on the blockchain execution client",
5793 cmd.instrument_id
5794 );
5795 Ok(())
5796 }
5797
5798 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
5799 for cancel in cmd.cancels {
5800 self.cancel_order(cancel)?;
5801 }
5802 Ok(())
5803 }
5804
5805 fn query_account(&self, cmd: QueryAccount) -> anyhow::Result<()> {
5806 anyhow::ensure!(
5807 cmd.account_id == self.core.account_id,
5808 "Query account ID {} does not match client account ID {}",
5809 cmd.account_id,
5810 self.core.account_id
5811 );
5812 anyhow::ensure!(self.core.is_started(), "Execution client is not started");
5813
5814 let balances = self.wallet_balance.lock().as_account_balances()?;
5815 self.generate_account_state(
5816 balances,
5817 vec![],
5818 true,
5819 get_atomic_clock_realtime().get_time_ns(),
5820 None,
5821 )
5822 }
5823
5824 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
5825 log::warn!(
5826 "Order queries are not supported on the blockchain execution client; cannot query {}",
5827 cmd.client_order_id
5828 );
5829 Ok(())
5830 }
5831
5832 async fn connect(&mut self) -> anyhow::Result<()> {
5833 if self.core.is_connected() {
5834 log::warn!("Blockchain execution client already connected");
5835 return Ok(());
5836 }
5837
5838 log::info!(
5839 "Connecting to blockchain execution client on chain {}",
5840 self.chain.name
5841 );
5842
5843 if !self.pending_tasks.is_open() || !self.pending_tasks.is_empty() {
5844 self.pending_tasks.begin_shutdown();
5845 self.pending_tasks
5846 .finish_shutdown(Duration::from_secs(5), Duration::from_secs(2))
5847 .await
5848 .map_err(|e| anyhow::anyhow!("Failed to terminate blockchain submissions: {e}"))?;
5849 self.signer = None;
5850 self.pending_tasks
5851 .start_generation()
5852 .map_err(|e| anyhow::anyhow!("Failed to start blockchain task generation: {e}"))?;
5853 }
5854 release_preparing_slot(&self.in_flight);
5855
5856 let setup_guard = TaskGroupGuard::new(&[&self.pending_tasks], || {});
5857
5858 let payload_keys = PayloadKeySet::load(
5859 self.config.payload_key_env.as_deref(),
5860 &self.config.payload_key_retired_env,
5861 self.config.payload_deployment_id.as_deref(),
5862 )?
5863 .map(Arc::new);
5864
5865 if self.cache.database.is_some() || self.config.postgres_cache_database_config.is_some() {
5866 let keys = payload_keys.as_deref().ok_or_else(|| {
5867 anyhow::anyhow!(
5868 "Postgres execution requires an active payload key and deployment identity"
5869 )
5870 })?;
5871
5872 if self.cache.database.is_none() {
5873 let pg_options = self
5874 .config
5875 .postgres_cache_database_config
5876 .as_ref()
5877 .expect("Postgres configuration checked above");
5878 let database = crate::cache::database::BlockchainCacheDatabase::connect(
5879 pg_options.clone().into(),
5880 )
5881 .await
5882 .map_err(|e| {
5883 anyhow::anyhow!("Failed to connect to the Postgres cache database: {e}")
5884 })?;
5885 self.cache.database = Some(database);
5886 }
5887 self.cache
5888 .database
5889 .as_ref()
5890 .expect("database was attached")
5891 .require_execution_payload_storage_ready(keys)
5892 .await?;
5893 self.cache.initialize_chain().await;
5894 self.cache.ensure_execution_transaction_schema().await?;
5895 let check = self
5896 .cache
5897 .database
5898 .as_ref()
5899 .expect("database was attached")
5900 .check_execution_payload_storage(
5901 Some(keys),
5902 Some(PayloadPolicy {
5903 chain_id: self.chain.chain_id,
5904 signer: self.wallet_address,
5905 gas_limit: self.config.gas_limit,
5906 max_fee_per_gas: self.config.max_fee_per_gas_wei,
5907 }),
5908 100,
5909 )
5910 .await?;
5911 anyhow::ensure!(
5912 check.protected,
5913 "Postgres execution requires protected payload storage"
5914 );
5915 } else {
5916 log::warn!(
5917 "No Postgres cache database configured; transactions will be refused (no durable store)"
5918 );
5919 }
5920 self.payload_keys = payload_keys;
5921
5922 let verification = self
5923 .config
5924 .verification
5925 .as_ref()
5926 .expect("verification config validated at construction");
5927 let position = if let Some(database) = self.cache.database.as_ref() {
5928 database
5929 .load_execution_verification_position(
5930 self.chain.chain_id,
5931 &self.config.wallet_address,
5932 &verification.manifest_version,
5933 &verification.manifest_digest,
5934 )
5935 .await?
5936 } else {
5937 None
5938 };
5939 let migration_snapshot = if position.is_none() {
5940 if let Some(database) = self.cache.database.as_ref() {
5941 let snapshot = database
5942 .load_execution_verification_migration_snapshot(
5943 self.chain.chain_id,
5944 &self.config.wallet_address,
5945 )
5946 .await?;
5947
5948 if snapshot.intents.is_empty() {
5949 None
5950 } else {
5951 Some(snapshot)
5952 }
5953 } else {
5954 None
5955 }
5956 } else {
5957 None
5958 };
5959
5960 let chain_id_verification = required_verification(
5961 self.verification.verify_chain_id().await,
5962 "Blockchain chain ID",
5963 )?;
5964 let checkpoint_verification = required_verification(
5965 self.verification.verify_checkpoint().await,
5966 "Blockchain checkpoint",
5967 )?;
5968 let checkpoint = checkpoint_verification.value;
5969 let finalized_verification = required_verification(
5970 self.verification.verify_finalized_header().await,
5971 "Blockchain finalized header",
5972 )?;
5973 let finalized = finalized_verification.value;
5974 let mut connect_decisions = vec![
5975 verification_decision(&chain_id_verification, None, None),
5976 verification_decision(
5977 &checkpoint_verification,
5978 Some(checkpoint.number),
5979 Some(checkpoint.number),
5980 ),
5981 verification_decision(
5982 &finalized_verification,
5983 Some(finalized.number),
5984 Some(finalized.number),
5985 ),
5986 ];
5987 let mut finalized_headers = if let Some(position) = position.as_ref() {
5988 let durable_tip = parse_verified_header(&position.finalized_tip)?;
5989 anyhow::ensure!(
5990 durable_tip.number >= checkpoint.number,
5991 "Durable finalized header tip precedes the trusted checkpoint"
5992 );
5993 let durable_tip_verification = required_verification(
5994 self.verification.verify_block(durable_tip.number).await,
5995 "Blockchain durable finalized tip",
5996 )?;
5997 anyhow::ensure!(
5998 durable_tip_verification.value == durable_tip,
5999 "Durable finalized header tip conflicts with independent sources"
6000 );
6001 connect_decisions.push(verification_decision(
6002 &durable_tip_verification,
6003 Some(durable_tip.number),
6004 Some(durable_tip.number),
6005 ));
6006 vec![durable_tip]
6007 } else {
6008 vec![checkpoint]
6009 };
6010 let mut ancestry_cursor = *finalized_headers
6011 .last()
6012 .expect("finalized header ledger is nonempty");
6013 anyhow::ensure!(
6014 finalized.number >= ancestry_cursor.number,
6015 "Verified finalized height regressed below the durable finalized header tip"
6016 );
6017
6018 while ancestry_cursor.number < finalized.number {
6019 let end = ancestry_cursor
6020 .number
6021 .saturating_add(4_096)
6022 .min(finalized.number);
6023 let start = ancestry_cursor.number.saturating_add(1);
6024 let headers_verification = required_verification(
6025 self.verification
6026 .verify_header_window(ancestry_cursor, end)
6027 .await,
6028 "Blockchain finalized ancestry",
6029 )?;
6030 let headers = &headers_verification.value;
6031 ancestry_cursor = *headers
6032 .last()
6033 .expect("nonempty ancestry window advances the cursor");
6034 finalized_headers.extend(headers.iter().copied());
6035 connect_decisions.push(verification_decision(
6036 &headers_verification,
6037 Some(start),
6038 Some(end),
6039 ));
6040 }
6041 anyhow::ensure!(
6042 ancestry_cursor == finalized,
6043 "Verified finalized header conflicts with its ancestry window"
6044 );
6045 let nonce_verification = required_verification(
6046 self.verification
6047 .verify_transaction_count(&self.wallet_address, finalized.number)
6048 .await,
6049 "Blockchain finalized transaction count",
6050 )?;
6051 let observed_canonical_nonce = nonce_verification.value;
6052 let next_canonical_nonce = position
6053 .as_ref()
6054 .map_or(observed_canonical_nonce, |position| {
6055 position.next_canonical_nonce
6056 });
6057
6058 if let Some(position) = position.as_ref() {
6059 log::debug!(
6060 "Resumed execution verification ledger at nonce revision {} with observed finalized nonce {}",
6061 position.revision,
6062 observed_canonical_nonce,
6063 );
6064 }
6065 connect_decisions.push(verification_decision(
6066 &nonce_verification,
6067 Some(finalized.number),
6068 Some(finalized.number),
6069 ));
6070 let deployment_verification = required_verification(
6071 self.verification
6072 .verify_deployment_manifest(&verification.deployment_manifest, finalized.number)
6073 .await,
6074 "Blockchain deployment manifest",
6075 )?;
6076 connect_decisions.push(verification_decision(
6077 &deployment_verification,
6078 Some(finalized.number),
6079 Some(finalized.number),
6080 ));
6081 connect_decisions.extend(
6082 verify_connect_capabilities(
6083 &self.verification,
6084 &verification.deployment_manifest,
6085 self.wallet_address,
6086 self.weth_address,
6087 finalized.number,
6088 )
6089 .await?,
6090 );
6091 let migration = if let Some(snapshot) = migration_snapshot {
6092 Some(
6093 self.build_execution_verification_migration(
6094 snapshot,
6095 finalized,
6096 &finalized_headers,
6097 &nonce_verification,
6098 )
6099 .await?,
6100 )
6101 } else {
6102 None
6103 };
6104
6105 if let Some(database) = self.cache.database.as_ref() {
6106 let identities = std::iter::once(&verification.authoritative)
6107 .chain(
6108 verification
6109 .verifiers
6110 .iter()
6111 .map(|provider| &provider.identity),
6112 )
6113 .collect::<Vec<_>>();
6114 let provider_ids = identities
6115 .iter()
6116 .map(|identity| identity.provider_id.clone())
6117 .collect::<Vec<_>>();
6118 let operator_ids = identities
6119 .iter()
6120 .map(|identity| identity.operator_id.clone())
6121 .collect::<Vec<_>>();
6122 let failure_domain_ids = identities
6123 .iter()
6124 .flat_map(|identity| identity.failure_domain_ids.iter().cloned())
6125 .collect::<Vec<_>>();
6126 let finalized_headers = finalized_headers
6127 .iter()
6128 .map(durable_verified_header)
6129 .collect::<Vec<_>>();
6130 database
6131 .ensure_execution_verification_schema(&ExecutionVerificationBootstrap {
6132 chain_id: self.chain.chain_id,
6133 wallet_address: &self.config.wallet_address,
6134 manifest_version: &verification.manifest_version,
6135 manifest_digest: &verification.manifest_digest,
6136 checkpoint_number: checkpoint.number,
6137 checkpoint_hash: &checkpoint.hash.to_string(),
6138 checkpoint_parent_hash: &checkpoint.parent_hash.to_string(),
6139 checkpoint_timestamp: checkpoint.timestamp,
6140 checkpoint_base_fee_per_gas: checkpoint.base_fee_per_gas,
6141 finalized_headers: &finalized_headers,
6142 next_canonical_nonce,
6143 observed_canonical_nonce,
6144 provider_ids: &provider_ids,
6145 operator_ids: &operator_ids,
6146 failure_domain_ids: &failure_domain_ids,
6147 decisions: &connect_decisions,
6148 migration: migration.as_ref(),
6149 })
6150 .await?;
6151 }
6152
6153 let payload_connect_lease = if let (Some(database), Some(keys)) =
6154 (self.cache.database.as_ref(), self.payload_keys.as_deref())
6155 {
6156 Some(
6157 database
6158 .require_execution_payload_storage(keys, self.payload_policy(), 100)
6159 .await?,
6160 )
6161 } else {
6162 None
6163 };
6164
6165 let private_key = Zeroizing::new(
6168 std::env::var(&self.config.signer_private_key_env).map_err(|_| {
6169 anyhow::anyhow!(
6170 "Signer private key environment variable '{}' is not set",
6171 self.config.signer_private_key_env
6172 )
6173 })?,
6174 );
6175 let encoded_key = private_key.trim();
6176 let encoded_key = encoded_key.strip_prefix("0x").unwrap_or(encoded_key);
6177 let key_bytes = Zeroizing::new(hex::decode_array::<32>(encoded_key).map_err(|_| {
6178 anyhow::anyhow!(
6179 "Signer private key in '{}' is not a valid hex private key",
6180 self.config.signer_private_key_env
6181 )
6182 })?);
6183 let signer = PrivateKeySigner::from_slice(&key_bytes[..]).map_err(|_| {
6184 anyhow::anyhow!(
6185 "Signer private key in '{}' is not a valid secp256k1 private key",
6186 self.config.signer_private_key_env
6187 )
6188 })?;
6189
6190 if signer.address() != self.wallet_address {
6191 anyhow::bail!(
6192 "Signer address {} derived from '{}' does not match configured wallet address {}",
6193 signer.address(),
6194 self.config.signer_private_key_env,
6195 self.wallet_address
6196 );
6197 }
6198
6199 self.signer = Some(Arc::new(signer));
6200 drop(payload_connect_lease);
6201
6202 if self.cache.has_database()
6203 && let Err(e) = self.reconcile_unresolved_execution().await
6204 {
6205 self.signer = None;
6206 return Err(e);
6207 }
6208
6209 if let Err(e) = self.refresh_wallet_balances().await {
6210 self.signer = None;
6211 return Err(e);
6212 }
6213 self.core.set_connected();
6214 setup_guard.disarm();
6215 log::info!(
6216 "Blockchain execution client connected on chain {}",
6217 self.chain.name
6218 );
6219 Ok(())
6220 }
6221
6222 async fn disconnect(&mut self) -> anyhow::Result<()> {
6223 self.pending_tasks.begin_shutdown();
6224 let tasks_result = self
6225 .pending_tasks
6226 .finish_shutdown(Duration::from_secs(5), Duration::from_secs(2))
6227 .await;
6228 self.signer = None;
6229 self.core.set_disconnected();
6230 tasks_result
6231 .map(|_| ())
6232 .map_err(|e| anyhow::anyhow!("Failed to terminate blockchain submissions: {e}"))?;
6233 Ok(())
6234 }
6235
6236 async fn generate_order_status_report(
6237 &self,
6238 _cmd: &GenerateOrderStatusReport,
6239 ) -> anyhow::Result<Option<OrderStatusReport>> {
6240 anyhow::bail!("{VENUE_EXECUTION_REPORTS_UNSUPPORTED}");
6241 }
6242
6243 async fn generate_order_status_reports(
6244 &self,
6245 _cmd: &GenerateOrderStatusReports,
6246 ) -> anyhow::Result<Vec<OrderStatusReport>> {
6247 anyhow::bail!("{VENUE_EXECUTION_REPORTS_UNSUPPORTED}");
6248 }
6249
6250 async fn generate_fill_reports(
6251 &self,
6252 _cmd: GenerateFillReports,
6253 ) -> anyhow::Result<Vec<FillReport>> {
6254 anyhow::bail!("{VENUE_EXECUTION_REPORTS_UNSUPPORTED}");
6255 }
6256
6257 async fn generate_position_status_reports(
6258 &self,
6259 _cmd: &GeneratePositionStatusReports,
6260 ) -> anyhow::Result<Vec<PositionStatusReport>> {
6261 anyhow::bail!("{VENUE_EXECUTION_REPORTS_UNSUPPORTED}");
6262 }
6263
6264 async fn generate_mass_status(
6265 &self,
6266 _lookback_mins: Option<u64>,
6267 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
6268 log::warn!(
6271 "Mass status is not supported on the blockchain execution client; skipping venue reconciliation"
6272 );
6273 Ok(None)
6274 }
6275}
6276
6277fn validate_order(order: &impl Order) -> Result<(), OrderDeniedReason> {
6278 if order.is_reduce_only() {
6279 return Err(OrderDeniedReason::UnsupportedReduceOnly);
6280 }
6281
6282 Ok(())
6283}
6284
6285#[cfg(test)]
6286mod tests {
6287 use std::{
6288 cell::RefCell,
6289 rc::Rc,
6290 sync::atomic::{AtomicU64, Ordering},
6291 };
6292
6293 use alloy::{
6294 primitives::{address, aliases::I24},
6295 sol_types::SolValue,
6296 };
6297 use nautilus_common::{
6298 cache::Cache, live::runner::replace_exec_event_sender, messages::ExecutionEvent,
6299 testing::wait_until_async,
6300 };
6301 use nautilus_core::UUID4;
6302 use nautilus_infrastructure::sql::pg::{PostgresConnectOptions, get_postgres_connect_options};
6303 use nautilus_model::{
6304 defi::{
6305 PoolProfiler,
6306 chain::chains,
6307 data::block::BlockPosition,
6308 pool_analysis::{
6309 position::PoolPosition,
6310 snapshot::{PoolAnalytics, PoolSnapshot, PoolState},
6311 },
6312 tick_map::{tick::PoolTick, tick_math::get_tick_at_sqrt_ratio},
6313 },
6314 enums::AccountType,
6315 events::OrderEventAny,
6316 identifiers::{OrderListId, StrategyId, TraderId},
6317 orders::{OrderList, OrderTestBuilder},
6318 types::Price,
6319 };
6320 use rstest::rstest;
6321 use sqlx::postgres::{PgAdvisoryLock, PgAdvisoryLockKey, PgPoolOptions};
6322
6323 use super::*;
6324 use crate::{
6325 cache::database::tests::connect_test_database,
6326 config::{
6327 BlockchainCallEdgeManifest, BlockchainChainAnchorConfig, BlockchainContractManifest,
6328 BlockchainContractProbe, BlockchainContractRole, BlockchainDeploymentManifest,
6329 BlockchainPoolManifest, BlockchainProviderIdentity, BlockchainTokenManifest,
6330 BlockchainVerificationConfig, BlockchainVerificationProviderConfig, QuoteSpendLimit,
6331 },
6332 constants::BLOCKCHAIN_VENUE,
6333 exchanges::arbitrum::UNISWAP_V3,
6334 rpc::http::{
6335 EXECUTION_RPC_TIMEOUT_SECS,
6336 tests::mock::{MockRpcState, start_mock_rpc_server},
6337 },
6338 };
6339
6340 async fn poll_for_receipt(
6343 http_rpc_client: &BlockchainHttpRpcClient,
6344 tx_hash: &B256,
6345 max_polls: u32,
6346 interval: Duration,
6347 ) -> anyhow::Result<Option<RpcTransactionReceipt>> {
6348 let mut last_error = None;
6349 let mut observed_pending = false;
6350
6351 for attempt in 0..max_polls {
6352 if attempt > 0 {
6353 tokio::time::sleep(interval).await;
6354 }
6355
6356 match http_rpc_client.get_transaction_receipt(tx_hash).await {
6357 Ok(Some(receipt)) => return Ok(Some(receipt)),
6358 Ok(None) => observed_pending = true,
6359 Err(e) => {
6360 log::warn!(
6361 "Receipt poll {}/{} for transaction {tx_hash} failed: {e}",
6362 attempt + 1,
6363 max_polls
6364 );
6365 last_error = Some(e);
6366 }
6367 }
6368 }
6369
6370 if !observed_pending && let Some(e) = last_error {
6371 return Err(e);
6372 }
6373
6374 Ok(None)
6375 }
6376
6377 const CHAIN_ID_ARBITRUM: &str =
6378 include_str!("../../test_data/execution/rpc_eth_chain_id_arbitrum.json");
6379 const CHAIN_ID_ETHEREUM: &str =
6380 include_str!("../../test_data/execution/rpc_eth_chain_id_ethereum.json");
6381 const GET_CODE_DEPLOYED: &str =
6382 include_str!("../../test_data/execution/rpc_eth_get_code_deployed.json");
6383 const GET_CODE_EMPTY: &str =
6384 include_str!("../../test_data/execution/rpc_eth_get_code_empty.json");
6385 const GET_BALANCE: &str = include_str!("../../test_data/execution/rpc_eth_get_balance.json");
6386 const GET_BALANCE_ZERO: &str =
6387 include_str!("../../test_data/execution/rpc_eth_get_balance_zero.json");
6388 const GET_BALANCE_INSUFFICIENT: &str =
6389 include_str!("../../test_data/execution/rpc_eth_get_balance_insufficient.json");
6390 const CALL_BALANCE: &str = include_str!("../../test_data/execution/rpc_eth_call_balance.json");
6391 const CALL_BALANCE_AFTER_WRAP: &str =
6392 include_str!("../../test_data/execution/rpc_eth_call_balance_after_wrap.json");
6393 const CALL_BALANCE_WETH: &str =
6394 include_str!("../../test_data/execution/rpc_eth_call_balance_weth.json");
6395 const CALL_BALANCE_USDC: &str =
6396 include_str!("../../test_data/execution/rpc_eth_call_balance_usdc.json");
6397 const CALL_BALANCE_WETH_UPDATED: &str =
6398 include_str!("../../test_data/execution/rpc_eth_call_balance_weth_updated.json");
6399 const CALL_BALANCE_USDC_UPDATED: &str =
6400 include_str!("../../test_data/execution/rpc_eth_call_balance_usdc_updated.json");
6401 const CALL_BOOL_TRUE: &str =
6402 include_str!("../../test_data/execution/rpc_eth_call_bool_true.json");
6403 const CALL_EMPTY: &str = include_str!("../../test_data/execution/rpc_eth_call_empty.json");
6404 const CALL_ZERO: &str = include_str!("../../test_data/execution/rpc_eth_call_zero.json");
6405 const CALL_ALLOWANCE: &str =
6406 include_str!("../../test_data/execution/rpc_eth_call_allowance.json");
6407 const CALL_ALLOWANCE_1000: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x00000000000000000000000000000000000000000000000000000000000003e8\"}";
6408 const CALL_ALLOWANCE_MAX: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"}";
6409 const CALL_FACTORY: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x0000000000000000000000001f98431c8ad98523631ae4a59f267346ea31f984\"}";
6410 const CALL_WETH: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x00000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1\"}";
6411 const CALL_USDC: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831\"}";
6412 const CALL_FEE_500: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x00000000000000000000000000000000000000000000000000000000000001f4\"}";
6413 const CALL_POOL: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x000000000000000000000000c6962004f452be9203591991d15f6b388e09e8d0\"}";
6414 const CALL_REVERTED: &str =
6415 r#"{"jsonrpc":"2.0","id":1,"error":{"code":3,"message":"execution reverted"}}"#;
6416 const STORAGE_ZERO: &str = r#"{"jsonrpc":"2.0","id":1,"result":"0x0000000000000000000000000000000000000000000000000000000000000000"}"#;
6417 const TRACE_UNKNOWN_TRANSACTION: &str =
6418 r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"transaction not found"}}"#;
6419 const CALL_DECIMALS_18: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x0000000000000000000000000000000000000000000000000000000000000012\"}";
6420 const CALL_DECIMALS_6: &str = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x0000000000000000000000000000000000000000000000000000000000000006\"}";
6421 const TRANSACTION_COUNT: &str =
6422 include_str!("../../test_data/execution/rpc_eth_get_transaction_count.json");
6423 const TRANSACTION_COUNT_NEXT: &str =
6424 include_str!("../../test_data/execution/rpc_eth_get_transaction_count_next.json");
6425 const ESTIMATE_GAS: &str = include_str!("../../test_data/execution/rpc_eth_estimate_gas.json");
6426 const MAX_PRIORITY_FEE: &str =
6427 include_str!("../../test_data/execution/rpc_eth_max_priority_fee_per_gas.json");
6428 const BLOCK_BY_NUMBER: &str =
6429 include_str!("../../test_data/execution/rpc_eth_get_block_by_number.json");
6430 const BLOCK_CANONICAL: &str =
6431 include_str!("../../test_data/execution/rpc_eth_get_block_canonical.json");
6432 const BLOCK_FINALIZED: &str =
6433 include_str!("../../test_data/execution/rpc_eth_get_block_finalized.json");
6434 const RECEIPT_SUCCESS: &str =
6435 include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_success.json");
6436 const RECEIPT_REVERTED: &str =
6437 include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_reverted.json");
6438 const RECEIPT_NULL: &str =
6439 include_str!("../../test_data/execution/rpc_eth_get_transaction_receipt_null.json");
6440 const SEND_RAW_TRANSACTION: &str =
6441 include_str!("../../test_data/execution/rpc_eth_send_raw_transaction.json");
6442 const SEND_RAW_TRANSACTION_REJECTED: &str =
6443 include_str!("../../test_data/execution/rpc_eth_send_raw_transaction_rejected.json");
6444 const SEND_RAW_TRANSACTION_NONCE_TOO_LOW: &str =
6445 include_str!("../../test_data/execution/rpc_eth_send_raw_transaction_nonce_too_low.json");
6446 const RPC_METHOD_NOT_FOUND: &str =
6447 include_str!("../../test_data/execution/rpc_error_method_not_found.json");
6448
6449 const WALLET: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
6450 const ROUTER: &str = "0xE592427A0AEce92De3Edee1F18E0157C05861564";
6451 const WETH: &str = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1";
6452 const USDC: &str = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";
6453 const TEST_TIMEOUT: Duration = Duration::from_secs(10);
6454
6455 const WETH_ADDRESS: Address = address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1");
6456 const USDC_ADDRESS: Address = address!("af88d065e77c8cC2239327C5EDb3A432268e5831");
6457 const ROUTER_ADDRESS: Address = address!("E592427A0AEce92De3Edee1F18E0157C05861564");
6458
6459 const TEST_PRIVATE_KEY: &str =
6461 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
6462
6463 const BALANCE_OF_SELECTOR: &str = "0x70a08231";
6464 const ALLOWANCE_SELECTOR: &str = "0xdd62ed3e";
6465 const POOL_TOKEN0_SELECTOR: &str = "0x0dfe1681";
6466 const POOL_TOKEN1_SELECTOR: &str = "0xd21220a7";
6467 const POOL_FEE_SELECTOR: &str = "0xddca3f43";
6468 const DECIMALS_SELECTOR: &str = "0x313ce567";
6469 const FACTORY_SELECTOR: &str = "0xc45a0155";
6470 const WETH9_SELECTOR: &str = "0x4aa4a4fc";
6471 const GET_POOL_SELECTOR: &str = "0x1698ee82";
6472 const QUOTE_EXACT_INPUT_SELECTOR: &str = "0xc6a5026a";
6473 const QUOTE_EXACT_OUTPUT_SELECTOR: &str = "0xbd21704a";
6474
6475 fn test_pool() -> Pool {
6476 let chain = Arc::new(chains::ARBITRUM.clone());
6477 let dex = UNISWAP_V3.dex.clone();
6478 let weth = Token::new(
6479 chain.clone(),
6480 address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
6481 "Wrapped Ether".to_string(),
6482 "WETH".to_string(),
6483 18,
6484 );
6485 let usdc = Token::new(
6486 chain.clone(),
6487 address!("af88d065e77c8cC2239327C5EDb3A432268e5831"),
6488 "USD Coin".to_string(),
6489 "USDC".to_string(),
6490 6,
6491 );
6492
6493 Pool::new(
6494 chain,
6495 dex,
6496 address!("C6962004f452bE9203591991D15f6b388e09E8D0"),
6497 PoolIdentifier::from_address(address!("C6962004f452bE9203591991D15f6b388e09E8D0")),
6498 55_000_000,
6499 weth,
6500 usdc,
6501 Some(500),
6502 Some(10),
6503 UnixNanos::default(),
6504 )
6505 }
6506
6507 fn test_config(http_rpc_url: String) -> BlockchainExecutionClientConfig {
6508 test_config_with_signer_env(http_rpc_url, "BLOCKCHAIN_TEST_PRIVATE_KEY")
6509 }
6510
6511 fn test_config_with_signer_env(
6512 http_rpc_url: String,
6513 signer_env: &str,
6514 ) -> BlockchainExecutionClientConfig {
6515 let verifier_separator = if http_rpc_url.contains('?') { '&' } else { '?' };
6516 let code_hash = keccak256(
6517 hex::decode("6080604052348015600e575f5ffd5b5060").expect("valid test bytecode"),
6518 )
6519 .to_string();
6520 let result = |response: &str| {
6521 serde_json::from_str::<serde_json::Value>(response).unwrap()["result"]
6522 .as_str()
6523 .unwrap()
6524 .to_string()
6525 };
6526 let probe = |call_data: &str, expected_output: String| BlockchainContractProbe {
6527 call_data: call_data.to_string(),
6528 expected_output,
6529 };
6530 let contract = |address: &str, role| {
6531 let probes = match role {
6532 BlockchainContractRole::Router => vec![
6533 probe(FACTORY_SELECTOR, result(CALL_FACTORY)),
6534 probe(WETH9_SELECTOR, result(CALL_WETH)),
6535 ],
6536 BlockchainContractRole::Factory => vec![probe(
6537 &hex::encode_prefixed(
6538 UniswapV3Factory::getPoolCall {
6539 tokenA: WETH_ADDRESS,
6540 tokenB: USDC_ADDRESS,
6541 fee: U24::try_from(500u32).unwrap(),
6542 }
6543 .abi_encode(),
6544 ),
6545 result(CALL_POOL),
6546 )],
6547 BlockchainContractRole::WrappedNative => {
6548 vec![probe(DECIMALS_SELECTOR, result(CALL_DECIMALS_18))]
6549 }
6550 BlockchainContractRole::Quote => {
6551 vec![probe(FACTORY_SELECTOR, result(CALL_FACTORY))]
6552 }
6553 BlockchainContractRole::Token => {
6554 vec![probe(DECIMALS_SELECTOR, result(CALL_DECIMALS_6))]
6555 }
6556 BlockchainContractRole::Pool => vec![
6557 probe(POOL_TOKEN0_SELECTOR, result(CALL_WETH)),
6558 probe(POOL_TOKEN1_SELECTOR, result(CALL_USDC)),
6559 probe(POOL_FEE_SELECTOR, result(CALL_FEE_500)),
6560 ],
6561 BlockchainContractRole::Implementation => Vec::new(),
6562 };
6563 BlockchainContractManifest {
6564 address: address.to_string(),
6565 role,
6566 runtime_code_hash: code_hash.clone(),
6567 proxy: None,
6568 probes,
6569 }
6570 };
6571 let deployment_manifest = BlockchainDeploymentManifest {
6572 version: "test-v1".to_string(),
6573 chain_id: chains::ARBITRUM.chain_id,
6574 chain_name: chains::ARBITRUM.name.to_string(),
6575 contracts: vec![
6576 contract(ROUTER, BlockchainContractRole::Router),
6577 contract(
6578 "0x1F98431c8aD98523631AE4a59f267346ea31F984",
6579 BlockchainContractRole::Factory,
6580 ),
6581 contract(WETH, BlockchainContractRole::WrappedNative),
6582 contract(
6583 "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
6584 BlockchainContractRole::Quote,
6585 ),
6586 contract(USDC, BlockchainContractRole::Token),
6587 contract(
6588 "0xC6962004f452bE9203591991D15f6b388e09E8D0",
6589 BlockchainContractRole::Pool,
6590 ),
6591 ],
6592 tokens: vec![
6593 BlockchainTokenManifest {
6594 address: WETH.to_string(),
6595 name: "Wrapped Ether".to_string(),
6596 symbol: "WETH".to_string(),
6597 decimals: 18,
6598 asset_role: "both".to_string(),
6599 },
6600 BlockchainTokenManifest {
6601 address: USDC.to_string(),
6602 name: "USD Coin".to_string(),
6603 symbol: "USDC".to_string(),
6604 decimals: 6,
6605 asset_role: "both".to_string(),
6606 },
6607 ],
6608 pools: vec![BlockchainPoolManifest {
6609 address: "0xC6962004f452bE9203591991D15f6b388e09E8D0".to_string(),
6610 token0: WETH.to_string(),
6611 token1: USDC.to_string(),
6612 fee: 500,
6613 factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984".to_string(),
6614 quote_contract: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e".to_string(),
6615 }],
6616 call_edges: ["swap_sell", "swap_buy"]
6617 .into_iter()
6618 .map(|purpose| BlockchainCallEdgeManifest {
6619 purpose: purpose.to_string(),
6620 caller: ROUTER.to_string(),
6621 target: "0xC6962004f452bE9203591991D15f6b388e09E8D0".to_string(),
6622 call_type: "call".to_string(),
6623 })
6624 .collect(),
6625 };
6626 let manifest_digest =
6627 keccak256(serde_json::to_vec(&deployment_manifest).unwrap()).to_string();
6628 let verification = BlockchainVerificationConfig {
6629 authoritative: BlockchainProviderIdentity {
6630 provider_id: "authoritative".to_string(),
6631 operator_id: "operator-a".to_string(),
6632 failure_domain_ids: vec!["domain-a".to_string()],
6633 },
6634 verifiers: vec![
6635 BlockchainVerificationProviderConfig {
6636 identity: BlockchainProviderIdentity {
6637 provider_id: "verifier-a".to_string(),
6638 operator_id: "operator-b".to_string(),
6639 failure_domain_ids: vec!["domain-b".to_string()],
6640 },
6641 http_rpc_url: format!("{http_rpc_url}{verifier_separator}source=verifier-a")
6642 .into(),
6643 },
6644 BlockchainVerificationProviderConfig {
6645 identity: BlockchainProviderIdentity {
6646 provider_id: "verifier-b".to_string(),
6647 operator_id: "operator-c".to_string(),
6648 failure_domain_ids: vec!["domain-c".to_string()],
6649 },
6650 http_rpc_url: format!("{http_rpc_url}{verifier_separator}source=verifier-b")
6651 .into(),
6652 },
6653 ],
6654 chain_anchor: BlockchainChainAnchorConfig {
6655 chain_id: chains::ARBITRUM.chain_id,
6656 chain_name: chains::ARBITRUM.name.to_string(),
6657 checkpoint_height: 30_346_560,
6658 checkpoint_hash:
6659 "0x1111111111111111111111111111111111111111111111111111111111111111".to_string(),
6660 checkpoint_timestamp: 1_761_888_800,
6661 max_head_skew_blocks: 3,
6662 max_head_age_secs: u64::MAX,
6663 max_future_drift_secs: u64::MAX,
6664 },
6665 manifest_version: "test-v1".to_string(),
6666 manifest_digest,
6667 deployment_manifest,
6668 };
6669 BlockchainExecutionClientConfig::builder()
6670 .client_id(AccountId::from("BLOCKCHAIN-001"))
6671 .chain(chains::ARBITRUM.clone())
6672 .wallet_address(WALLET.to_string())
6673 .http_rpc_url(http_rpc_url.into())
6674 .verification(verification)
6675 .signer_private_key_env(signer_env.to_string())
6676 .router_addresses(vec![ROUTER.to_string()])
6677 .weth_address(WETH.to_string())
6678 .max_fee_per_gas_wei(1_000_000_000)
6679 .base_fee_buffer_bps(2_000)
6680 .gas_limit(1_000_000)
6681 .gas_buffer_bps(2_000)
6682 .allowed_token_pairs(vec![(WETH.to_string(), USDC.to_string())])
6683 .slippage_bps(50)
6684 .max_slippage_bps(200)
6685 .max_order_amount(1_000_000_000_000_000_000)
6686 .deadline_seconds(300)
6687 .max_quote_age_blocks(100)
6688 .receipt_timeout_secs(1)
6689 .build()
6690 }
6691
6692 fn buy_test_config(http_rpc_url: String) -> BlockchainExecutionClientConfig {
6693 let max_amount = expected_buy_amount_in().to_string();
6694 let mut config = test_config(http_rpc_url);
6695 config.allowed_token_pairs = Some(vec![
6696 (WETH.to_string(), USDC.to_string()),
6697 (USDC.to_string(), WETH.to_string()),
6698 ]);
6699 config.quote_spend_limits = Some(vec![quote_spend_limit(USDC, WETH, 6, &max_amount)]);
6700 config
6701 }
6702
6703 fn refresh_test_manifest_digest(config: &mut BlockchainExecutionClientConfig) {
6704 let verification = config.verification.as_mut().unwrap();
6705 verification.manifest_digest =
6706 keccak256(serde_json::to_vec(&verification.deployment_manifest).unwrap()).to_string();
6707 }
6708
6709 fn quote_spend_limit(
6710 token_in: &str,
6711 token_out: &str,
6712 spend_token_decimals: u8,
6713 max_amount: &str,
6714 ) -> QuoteSpendLimit {
6715 QuoteSpendLimit::builder()
6716 .token_in(token_in.to_string())
6717 .token_out(token_out.to_string())
6718 .spend_token(token_in.to_string())
6719 .spend_token_decimals(spend_token_decimals)
6720 .max_amount(max_amount.to_string())
6721 .build()
6722 }
6723
6724 fn test_client_from_config(
6725 config: BlockchainExecutionClientConfig,
6726 pool: Pool,
6727 ) -> BlockchainExecutionClient {
6728 test_client_result(config, pool).unwrap()
6729 }
6730
6731 fn test_client_result(
6732 config: BlockchainExecutionClientConfig,
6733 pool: Pool,
6734 ) -> anyhow::Result<BlockchainExecutionClient> {
6735 test_client_and_cache(config, pool).map(|(client, _)| client)
6736 }
6737
6738 fn test_client_and_cache(
6739 config: BlockchainExecutionClientConfig,
6740 pool: Pool,
6741 ) -> anyhow::Result<(BlockchainExecutionClient, Rc<RefCell<Cache>>)> {
6742 let cache = Rc::new(RefCell::new(Cache::default()));
6743 cache.borrow_mut().add_pool(pool).unwrap();
6744 let core = ExecutionClientCore::new(
6745 TraderId::from("TRADER-001"),
6746 ClientId::from("BLOCKCHAIN-001"),
6747 *BLOCKCHAIN_VENUE,
6748 OmsType::Netting,
6749 AccountId::from("BLOCKCHAIN-001"),
6750 AccountType::Wallet,
6751 None,
6752 cache.clone(),
6753 );
6754
6755 let client = BlockchainExecutionClient::new(core, config)?;
6756 Ok((client, cache))
6757 }
6758
6759 fn test_client(http_rpc_url: String) -> BlockchainExecutionClient {
6760 test_client_from_config(test_config(http_rpc_url), test_pool())
6761 }
6762
6763 async fn client_with_mock_rpc(
6764 state: MockRpcState,
6765 ) -> (BlockchainExecutionClient, MockRpcState) {
6766 let addr = start_mock_rpc_server(state.clone()).await;
6767 (test_client(format!("http://{addr}")), state)
6768 }
6769
6770 async fn client_with_token_mock_rpc(
6771 state: MockRpcState,
6772 signer_env: &str,
6773 ) -> (BlockchainExecutionClient, MockRpcState, Rc<RefCell<Cache>>) {
6774 let state = with_connect_capabilities(state);
6775 let addr = start_mock_rpc_server(state.clone()).await;
6776 let pool = test_pool();
6777 let tokens = [pool.token0.clone(), pool.token1.clone()];
6778 let mut config = test_config_with_signer_env(format!("http://{addr}"), signer_env);
6779 config.tokens = Some(vec![WETH.to_string(), USDC.to_string()]);
6780 let (mut client, cache) = test_client_and_cache(config, pool).unwrap();
6781 for token in tokens {
6782 client.cache.add_token(token).await.unwrap();
6783 }
6784 (client, state, cache)
6785 }
6786
6787 fn execution_rpc_state() -> MockRpcState {
6788 MockRpcState::default()
6789 .with_receipt_hash_from_request()
6790 .with_response("eth_chainId", CHAIN_ID_ARBITRUM)
6791 .with_response("eth_getCode", GET_CODE_DEPLOYED)
6792 .with_response("eth_getBalance", GET_BALANCE)
6793 .with_response("eth_getBlockByNumber", BLOCK_BY_NUMBER)
6794 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
6795 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", BLOCK_CANONICAL)
6796 .with_parameter_response("eth_getBlockByNumber", "finalized", BLOCK_FINALIZED)
6797 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d42", BLOCK_FINALIZED)
6798 .with_response("eth_maxPriorityFeePerGas", MAX_PRIORITY_FEE)
6799 .with_call_response(FACTORY_SELECTOR, CALL_FACTORY)
6800 .with_call_response(WETH9_SELECTOR, CALL_WETH)
6801 .with_call_response(GET_POOL_SELECTOR, CALL_POOL)
6802 .with_call_response(POOL_TOKEN0_SELECTOR, CALL_WETH)
6803 .with_call_response(POOL_TOKEN1_SELECTOR, CALL_USDC)
6804 .with_call_response(POOL_FEE_SELECTOR, CALL_FEE_500)
6805 .with_contract_call_response(WETH, DECIMALS_SELECTOR, CALL_DECIMALS_18)
6806 .with_contract_call_response(USDC, DECIMALS_SELECTOR, CALL_DECIMALS_6)
6807 .with_call_response(
6808 QUOTE_EXACT_INPUT_SELECTOR,
6809 "e_response(expected_sell_quote_amount()),
6810 )
6811 .with_call_response(
6812 QUOTE_EXACT_OUTPUT_SELECTOR,
6813 "e_response(expected_buy_amount_in()),
6814 )
6815 }
6816
6817 fn ready_rpc_state() -> MockRpcState {
6818 with_connect_capabilities(execution_rpc_state())
6819 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE)
6820 .with_call_response(ALLOWANCE_SELECTOR, CALL_ALLOWANCE)
6821 }
6822
6823 fn with_connect_capabilities(state: MockRpcState) -> MockRpcState {
6824 state
6825 .with_response("eth_getStorageAt", STORAGE_ZERO)
6826 .with_response("eth_estimateGas", ESTIMATE_GAS)
6827 .with_parameter_response(
6828 "debug_traceTransaction",
6829 &B256::ZERO.to_string(),
6830 TRACE_UNKNOWN_TRANSACTION,
6831 )
6832 }
6833
6834 fn signing_rpc_state() -> MockRpcState {
6835 ready_rpc_state()
6836 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
6837 .with_response("eth_estimateGas", ESTIMATE_GAS)
6838 }
6839
6840 fn broadcast_rpc_state() -> MockRpcState {
6841 execution_rpc_state()
6842 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
6843 .with_response("eth_estimateGas", ESTIMATE_GAS)
6844 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS)
6845 .with_send_raw_transaction_echo()
6846 .with_call_response_sequence(ALLOWANCE_SELECTOR, &[CALL_ZERO, CALL_ALLOWANCE_1000])
6847 }
6848
6849 async fn expected_wrap_tx_hash(value: U256) -> B256 {
6850 expected_tx_hash(
6851 WETH_ADDRESS,
6852 value,
6853 Bytes::from(nautilus_core::hex::decode("d0e30db0").unwrap()),
6854 )
6855 .await
6856 }
6857
6858 async fn expected_approve_tx_hash(amount: U256) -> B256 {
6859 let calldata = ERC20::approveCall {
6860 spender: ROUTER_ADDRESS,
6861 amount,
6862 }
6863 .abi_encode();
6864 expected_tx_hash(WETH_ADDRESS, U256::ZERO, Bytes::from(calldata)).await
6865 }
6866
6867 async fn expected_tx_hash(to: Address, value: U256, input: Bytes) -> B256 {
6868 let expected_tx =
6871 build_eip1559_transaction(42161, 7, 78_000, 130_000_000, 10_000_000, to, value, input);
6872 let (expected_hash, _) = sign_eip1559_transaction(
6873 expected_tx,
6874 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
6875 )
6876 .await
6877 .unwrap();
6878 expected_hash
6879 }
6880
6881 async fn await_recorded_requests(state: &MockRpcState, method: &str, expected: usize) {
6882 wait_until_async(
6883 || async {
6884 state
6885 .recorded_requests()
6886 .iter()
6887 .filter(|request| request["method"] == method)
6888 .count()
6889 >= expected
6890 },
6891 TEST_TIMEOUT,
6892 )
6893 .await;
6894 }
6895
6896 const FIXTURE_BLOCK: u64 = 30_346_560;
6899 const FIXTURE_BLOCK_PARAM: &str = "0x1cf0d40";
6900 const FIXTURE_BLOCK_HASH: &str =
6901 "0x1111111111111111111111111111111111111111111111111111111111111111";
6902 const FIXTURE_BLOCK_TIMESTAMP: u64 = 1_761_888_800;
6904 const TEST_LIQUIDITY: u128 = 1_000_000_000_000_000_000_000;
6906
6907 fn test_profiler(pool: &Pool, block_number: u64) -> PoolProfiler {
6908 test_profiler_at_block(pool, block_number, FIXTURE_BLOCK_HASH)
6909 }
6910
6911 fn test_profiler_at_block(pool: &Pool, block_number: u64, block_hash: &str) -> PoolProfiler {
6912 test_profiler_with_range(
6913 pool,
6914 block_number,
6915 block_hash,
6916 U160::from(1u128 << 96),
6917 -887_220,
6918 887_220,
6919 TEST_LIQUIDITY,
6920 )
6921 }
6922
6923 fn test_profiler_with_state(
6924 pool: &Pool,
6925 block_number: u64,
6926 sqrt_price_x96: U160,
6927 liquidity: u128,
6928 ) -> PoolProfiler {
6929 test_profiler_with_range(
6930 pool,
6931 block_number,
6932 FIXTURE_BLOCK_HASH,
6933 sqrt_price_x96,
6934 -887_220,
6935 887_220,
6936 liquidity,
6937 )
6938 }
6939
6940 fn test_profiler_with_range(
6944 pool: &Pool,
6945 block_number: u64,
6946 block_hash: &str,
6947 sqrt_price_x96: U160,
6948 tick_lower: i32,
6949 tick_upper: i32,
6950 liquidity: u128,
6951 ) -> PoolProfiler {
6952 let snapshot = PoolSnapshot::new(
6953 pool.instrument_id,
6954 PoolState {
6955 current_tick: get_tick_at_sqrt_ratio(sqrt_price_x96),
6956 price_sqrt_ratio_x96: sqrt_price_x96,
6957 liquidity,
6958 protocol_fees_token0: U256::ZERO,
6959 protocol_fees_token1: U256::ZERO,
6960 fee_protocol: 0,
6961 fee_protocol0_basis_points: None,
6962 fee_protocol1_basis_points: None,
6963 fee_growth_global_0: U256::ZERO,
6964 fee_growth_global_1: U256::ZERO,
6965 },
6966 vec![PoolPosition::new(
6967 address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"),
6968 tick_lower,
6969 tick_upper,
6970 liquidity as i128,
6971 )],
6972 vec![
6973 PoolTick::new(
6974 tick_lower,
6975 liquidity,
6976 liquidity as i128,
6977 U256::ZERO,
6978 U256::ZERO,
6979 true,
6980 0,
6981 ),
6982 PoolTick::new(
6983 tick_upper,
6984 liquidity,
6985 -(liquidity as i128),
6986 U256::ZERO,
6987 U256::ZERO,
6988 true,
6989 0,
6990 ),
6991 ],
6992 PoolAnalytics::default(),
6993 BlockPosition::new(
6994 block_number,
6995 block_hash.to_string(),
6996 BLOCK_SCOPED_SNAPSHOT_INDEX,
6997 BLOCK_SCOPED_SNAPSHOT_INDEX,
6998 )
6999 .with_block_hash(Some(block_hash.to_string())),
7000 UnixNanos::default(),
7001 UnixNanos::default(),
7002 );
7003 let mut profiler = PoolProfiler::new(Arc::new(pool.clone()));
7004 profiler.restore_from_snapshot(snapshot).unwrap();
7005 profiler
7006 }
7007
7008 fn test_market_sell_order(instrument_id: InstrumentId) -> OrderAny {
7009 market_sell_order_with_id(instrument_id, "O-SWAP-001")
7010 }
7011
7012 fn test_market_buy_order(instrument_id: InstrumentId) -> OrderAny {
7013 market_buy_order_with_id(instrument_id, "O-SWAP-BUY-001")
7014 }
7015
7016 fn submit_order_cmd(order: &OrderAny) -> SubmitOrder {
7017 SubmitOrder::new(
7018 TraderId::from("TRADER-001"),
7019 Some(ClientId::from("BLOCKCHAIN-001")),
7020 order.strategy_id(),
7021 order.instrument_id(),
7022 order.client_order_id(),
7023 order.init_event().clone(),
7024 None,
7025 None,
7026 None,
7027 UUID4::new(),
7028 UnixNanos::default(),
7029 None,
7030 )
7031 }
7032
7033 fn swap_client_with_cache(
7036 config: BlockchainExecutionClientConfig,
7037 ) -> (BlockchainExecutionClient, Rc<RefCell<Cache>>) {
7038 let cache = Rc::new(RefCell::new(Cache::default()));
7039 let pool = test_pool();
7040 cache.borrow_mut().add_pool(pool.clone()).unwrap();
7041 cache
7042 .borrow_mut()
7043 .add_order(
7044 test_market_sell_order(pool.instrument_id),
7045 None,
7046 None,
7047 false,
7048 )
7049 .unwrap();
7050 cache
7051 .borrow_mut()
7052 .add_pool_profiler(test_profiler_at_block(
7053 &pool,
7054 FIXTURE_BLOCK,
7055 FIXTURE_BLOCK_HASH,
7056 ))
7057 .unwrap();
7058 let core = ExecutionClientCore::new(
7059 TraderId::from("TRADER-001"),
7060 ClientId::from("BLOCKCHAIN-001"),
7061 *BLOCKCHAIN_VENUE,
7062 OmsType::Netting,
7063 AccountId::from("BLOCKCHAIN-001"),
7064 AccountType::Wallet,
7065 None,
7066 cache.clone(),
7067 );
7068
7069 let client = BlockchainExecutionClient::new(core, config).unwrap();
7070 (client, cache)
7071 }
7072
7073 async fn swap_client_with_database(
7075 test_name: &str,
7076 state: MockRpcState,
7077 ) -> Option<(
7078 sqlx::PgPool,
7079 String,
7080 BlockchainExecutionClient,
7081 MockRpcState,
7082 Rc<RefCell<Cache>>,
7083 )> {
7084 swap_client_with_database_config(test_name, state, test_config).await
7085 }
7086
7087 async fn swap_client_with_buy_database(
7088 test_name: &str,
7089 state: MockRpcState,
7090 ) -> Option<(
7091 sqlx::PgPool,
7092 String,
7093 BlockchainExecutionClient,
7094 MockRpcState,
7095 Rc<RefCell<Cache>>,
7096 )> {
7097 swap_client_with_database_config(test_name, state, buy_test_config).await
7098 }
7099
7100 async fn swap_client_with_database_config<F>(
7101 test_name: &str,
7102 state: MockRpcState,
7103 config: F,
7104 ) -> Option<(
7105 sqlx::PgPool,
7106 String,
7107 BlockchainExecutionClient,
7108 MockRpcState,
7109 Rc<RefCell<Cache>>,
7110 )>
7111 where
7112 F: FnOnce(String) -> BlockchainExecutionClientConfig,
7113 {
7114 let (admin_pool, pg_config) = connect_test_postgres(test_name).await?;
7115 let schema = format!("{test_name}_{}", std::process::id());
7116 setup_execution_schema(&admin_pool, &schema).await;
7117
7118 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
7119 let db_options = db_options.options([("search_path", schema.clone())]);
7120 let database = connect_test_database(db_options).await.unwrap();
7121 let addr = start_mock_rpc_server(state.clone()).await;
7122 let (mut client, cache) = swap_client_with_cache(config(format!("http://{addr}")));
7123 client.cache.database = Some(database);
7124 client
7126 .cache
7127 .ensure_execution_transaction_schema()
7128 .await
7129 .unwrap();
7130 protect_test_storage(&mut client, &schema).await;
7131 initialize_test_verification_ledger(&client).await;
7132 client.signer = Some(Arc::new(
7133 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7134 ));
7135 client.core.set_connected();
7136
7137 Some((admin_pool, schema, client, state, cache))
7138 }
7139
7140 async fn initialize_test_verification_ledger(client: &BlockchainExecutionClient) {
7141 initialize_test_verification_ledger_with_headers(
7142 client,
7143 &[ExecutionVerifiedHeader {
7144 number: FIXTURE_BLOCK,
7145 hash: FIXTURE_BLOCK_HASH.to_string(),
7146 parent_hash: "0x0000000000000000000000000000000000000000000000000000000000000001"
7147 .to_string(),
7148 timestamp: FIXTURE_BLOCK_TIMESTAMP,
7149 base_fee_per_gas: Some(100_000_000),
7150 }],
7151 )
7152 .await;
7153 }
7154
7155 async fn initialize_test_verification_ledger_with_headers(
7156 client: &BlockchainExecutionClient,
7157 finalized_headers: &[ExecutionVerifiedHeader],
7158 ) {
7159 ensure_test_verification_ledger(client, finalized_headers, 7, 7)
7160 .await
7161 .unwrap();
7162 }
7163
7164 async fn ensure_test_verification_ledger(
7165 client: &BlockchainExecutionClient,
7166 finalized_headers: &[ExecutionVerifiedHeader],
7167 next_canonical_nonce: u64,
7168 observed_canonical_nonce: u64,
7169 ) -> anyhow::Result<()> {
7170 let verification = client.config.verification.as_ref().unwrap();
7171 let provider_ids = vec![
7172 "authoritative".to_string(),
7173 "verifier-a".to_string(),
7174 "verifier-b".to_string(),
7175 ];
7176 let operator_ids = vec![
7177 "operator-a".to_string(),
7178 "operator-b".to_string(),
7179 "operator-c".to_string(),
7180 ];
7181 let failure_domain_ids = vec![
7182 "domain-a".to_string(),
7183 "domain-b".to_string(),
7184 "domain-c".to_string(),
7185 ];
7186 let decisions = [ExecutionVerificationDecision {
7187 read_class: "numbered_block",
7188 height_start: Some(FIXTURE_BLOCK),
7189 height_end: Some(FIXTURE_BLOCK),
7190 normalized_value_digest: B256::ZERO.to_string(),
7191 }];
7192 client
7193 .cache
7194 .database
7195 .as_ref()
7196 .unwrap()
7197 .ensure_execution_verification_schema(&ExecutionVerificationBootstrap {
7198 chain_id: 42_161,
7199 wallet_address: WALLET,
7200 manifest_version: &verification.manifest_version,
7201 manifest_digest: &verification.manifest_digest,
7202 checkpoint_number: FIXTURE_BLOCK,
7203 checkpoint_hash: FIXTURE_BLOCK_HASH,
7204 checkpoint_parent_hash:
7205 "0x0000000000000000000000000000000000000000000000000000000000000001",
7206 checkpoint_timestamp: FIXTURE_BLOCK_TIMESTAMP,
7207 checkpoint_base_fee_per_gas: Some(100_000_000),
7208 finalized_headers,
7209 next_canonical_nonce,
7210 observed_canonical_nonce,
7211 provider_ids: &provider_ids,
7212 operator_ids: &operator_ids,
7213 failure_domain_ids: &failure_domain_ids,
7214 decisions: &decisions,
7215 migration: None,
7216 })
7217 .await
7218 }
7219
7220 async fn initialize_test_verification_migration(
7221 client: &BlockchainExecutionClient,
7222 finalized_headers: &[ExecutionVerifiedHeader],
7223 next_canonical_nonce: u64,
7224 decisions: &[ExecutionVerificationDecision],
7225 migration: &ExecutionVerificationMigration,
7226 ) {
7227 let verification = client.config.verification.as_ref().unwrap();
7228 let provider_ids = vec![
7229 "authoritative".to_string(),
7230 "verifier-a".to_string(),
7231 "verifier-b".to_string(),
7232 ];
7233 let operator_ids = vec![
7234 "operator-a".to_string(),
7235 "operator-b".to_string(),
7236 "operator-c".to_string(),
7237 ];
7238 let failure_domain_ids = vec![
7239 "domain-a".to_string(),
7240 "domain-b".to_string(),
7241 "domain-c".to_string(),
7242 ];
7243 client
7244 .cache
7245 .database
7246 .as_ref()
7247 .unwrap()
7248 .ensure_execution_verification_schema(&ExecutionVerificationBootstrap {
7249 chain_id: 42_161,
7250 wallet_address: WALLET,
7251 manifest_version: &verification.manifest_version,
7252 manifest_digest: &verification.manifest_digest,
7253 checkpoint_number: FIXTURE_BLOCK,
7254 checkpoint_hash: FIXTURE_BLOCK_HASH,
7255 checkpoint_parent_hash:
7256 "0x0000000000000000000000000000000000000000000000000000000000000001",
7257 checkpoint_timestamp: FIXTURE_BLOCK_TIMESTAMP,
7258 checkpoint_base_fee_per_gas: Some(100_000_000),
7259 finalized_headers,
7260 next_canonical_nonce,
7261 observed_canonical_nonce: next_canonical_nonce,
7262 provider_ids: &provider_ids,
7263 operator_ids: &operator_ids,
7264 failure_domain_ids: &failure_domain_ids,
7265 decisions,
7266 migration: Some(migration),
7267 })
7268 .await
7269 .unwrap();
7270 }
7271
7272 async fn swap_rpc_state() -> MockRpcState {
7273 let min_amount_out = expected_min_amount_out(50);
7274 swap_rpc_state_with_min_amount_out(min_amount_out).await
7275 }
7276
7277 async fn swap_rpc_state_with_min_amount_out(min_amount_out: U256) -> MockRpcState {
7278 let (tx_hash, _) = expected_swap_tx(min_amount_out).await;
7279 finalized_swap_rpc_state(tx_hash, min_amount_out)
7280 }
7281
7282 async fn swap_rpc_state_for_mismatch() -> MockRpcState {
7283 swap_rpc_state()
7284 .await
7285 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION)
7286 }
7287
7288 fn awaiting_in_flight(client: &BlockchainExecutionClient) -> InFlightTransaction {
7289 let slot = *client.in_flight.lock();
7290 let Some(InFlightSlot::AwaitingFinality(in_flight)) = slot else {
7291 panic!("expected an awaiting-finality transaction, was {slot:?}");
7292 };
7293 in_flight
7294 }
7295
7296 fn recovering_in_flight(client: &BlockchainExecutionClient) -> RecoveryTransaction {
7297 let slot = *client.in_flight.lock();
7298 let Some(InFlightSlot::Recovering(recovery)) = slot else {
7299 panic!("expected a recovery transaction, was {slot:?}");
7300 };
7301 recovery
7302 }
7303
7304 async fn execution_intent_markers(
7305 admin_pool: &sqlx::PgPool,
7306 schema: &str,
7307 ) -> Vec<(String, String, bool, bool)> {
7308 sqlx::query_as(sqlx::AssertSqlSafe(format!(
7309 "SELECT purpose, status, terminal_emitted, active \
7310 FROM {schema}.execution_intent ORDER BY id"
7311 )))
7312 .fetch_all(admin_pool)
7313 .await
7314 .unwrap()
7315 }
7316
7317 #[allow(unsafe_code)] fn payload_test_keys(
7319 active: [u8; 32],
7320 retired: Vec<[u8; 32]>,
7321 deployment_id: &str,
7322 ) -> PayloadKeySet {
7323 static NEXT_ENV_ID: AtomicU64 = AtomicU64::new(0);
7324
7325 let env_id = NEXT_ENV_ID.fetch_add(1, Ordering::Relaxed);
7326 let active_env = format!("BLOCKCHAIN_TEST_PAYLOAD_KEY_{env_id}_ACTIVE");
7327 let retired_envs = retired
7328 .iter()
7329 .enumerate()
7330 .map(|(index, _)| format!("BLOCKCHAIN_TEST_PAYLOAD_KEY_{env_id}_RETIRED_{index}"))
7331 .collect::<Vec<_>>();
7332 unsafe { std::env::set_var(&active_env, hex::encode(active)) };
7334 for (env, key) in retired_envs.iter().zip(&retired) {
7335 unsafe { std::env::set_var(env, hex::encode(key)) };
7337 }
7338
7339 let keys = PayloadKeySet::load(Some(&active_env), &retired_envs, Some(deployment_id))
7340 .unwrap()
7341 .unwrap();
7342
7343 unsafe { std::env::remove_var(active_env) };
7345 for env in retired_envs {
7346 unsafe { std::env::remove_var(env) };
7348 }
7349 keys
7350 }
7351
7352 #[allow(unsafe_code)] fn set_test_payload_key(
7354 client: &mut BlockchainExecutionClient,
7355 key: [u8; 32],
7356 deployment_id: &str,
7357 ) -> PayloadKeySet {
7358 static NEXT_ENV_ID: AtomicU64 = AtomicU64::new(0);
7359
7360 let env_id = NEXT_ENV_ID.fetch_add(1, Ordering::Relaxed);
7361 let active_env = format!("BLOCKCHAIN_TEST_EXECUTION_KEY_{env_id}");
7362 unsafe { std::env::set_var(&active_env, hex::encode(key)) };
7364 client.config.payload_key_env = Some(active_env);
7365 client.config.payload_deployment_id = Some(deployment_id.to_string());
7366 client.load_payload_keys().unwrap().unwrap()
7367 }
7368
7369 async fn protect_test_storage(client: &mut BlockchainExecutionClient, deployment_id: &str) {
7370 let keys = set_test_payload_key(client, [0xa5; 32], deployment_id);
7371 client
7372 .cache
7373 .database
7374 .as_ref()
7375 .unwrap()
7376 .ensure_execution_payload_storage(&keys)
7377 .await
7378 .unwrap();
7379 client.payload_keys = Some(Arc::new(keys));
7380 }
7381
7382 async fn reserve_test_wrap_intent(database: &BlockchainCacheDatabase) -> ExecutionIntentRow {
7383 reserve_test_wrap_intent_for_wallet(database, WALLET).await
7384 }
7385
7386 async fn reserve_test_wrap_intent_for_wallet(
7387 database: &BlockchainCacheDatabase,
7388 wallet: &str,
7389 ) -> ExecutionIntentRow {
7390 database
7391 .reserve_execution_intent(&ExecutionIntentInsert {
7392 chain_id: 42161,
7393 wallet_address: wallet.to_string(),
7394 purpose: "wrap".to_string(),
7395 client_order_id: None,
7396 trader_id: None,
7397 strategy_id: None,
7398 account_id: None,
7399 instrument_id: None,
7400 pool_address: None,
7401 transaction_to: WETH_ADDRESS.to_string(),
7402 transaction_input: "0xd0e30db0".to_string(),
7403 transaction_value: "1".to_string(),
7404 amount_in: None,
7405 created_block: FIXTURE_BLOCK,
7406 })
7407 .await
7408 .unwrap()
7409 }
7410
7411 async fn persist_test_wrap_broadcast(
7412 database: &BlockchainCacheDatabase,
7413 keys: Option<&PayloadKeySet>,
7414 ) -> (ExecutionIntentRow, B256, Vec<u8>) {
7415 let intent = reserve_test_wrap_intent(database).await;
7416 database
7417 .assign_execution_intent_nonce(intent.id, 7)
7418 .await
7419 .unwrap();
7420 let intent = database.get_execution_intent(intent.id).await.unwrap();
7421 let transaction = build_eip1559_transaction(
7422 42161,
7423 7,
7424 78_000,
7425 130_000_000,
7426 10_000_000,
7427 WETH_ADDRESS,
7428 U256::from(1u64),
7429 Bytes::from(nautilus_core::hex::decode("d0e30db0").unwrap()),
7430 );
7431 let (tx_hash, raw_tx) = sign_eip1559_transaction(
7432 transaction,
7433 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7434 )
7435 .await
7436 .unwrap();
7437 persist_test_payload(database, keys, &intent, tx_hash, &raw_tx).await;
7438 database
7439 .record_execution_status(
7440 intent.id,
7441 &tx_hash.to_string(),
7442 TransactionStatus::Broadcast,
7443 None,
7444 None,
7445 None,
7446 None,
7447 None,
7448 )
7449 .await
7450 .unwrap();
7451 (intent, tx_hash, raw_tx)
7452 }
7453
7454 async fn reserve_test_swap_intent(database: &BlockchainCacheDatabase) -> ExecutionIntentRow {
7455 let pool = test_pool();
7456 let order = test_market_sell_order(pool.instrument_id);
7457 let calldata = expected_swap_calldata(expected_min_amount_out(50));
7458
7459 database
7460 .reserve_execution_intent(&ExecutionIntentInsert {
7461 chain_id: 42161,
7462 wallet_address: WALLET.to_string(),
7463 purpose: "swap".to_string(),
7464 client_order_id: Some(order.client_order_id().to_string()),
7465 trader_id: Some(order.trader_id().to_string()),
7466 strategy_id: Some(order.strategy_id().to_string()),
7467 account_id: Some("BLOCKCHAIN-001".to_string()),
7468 instrument_id: Some(pool.instrument_id.to_string()),
7469 pool_address: Some(pool.address.to_string()),
7470 transaction_to: ROUTER_ADDRESS.to_string(),
7471 transaction_input: hex::encode_prefixed(&calldata),
7472 transaction_value: U256::ZERO.to_string(),
7473 amount_in: Some("1000000000000000".to_string()),
7474 created_block: FIXTURE_BLOCK,
7475 })
7476 .await
7477 .unwrap()
7478 }
7479
7480 async fn persist_invalid_test_swap(
7481 database: &BlockchainCacheDatabase,
7482 keys: Option<&PayloadKeySet>,
7483 ) -> (ExecutionIntentRow, B256) {
7484 let intent = reserve_test_swap_intent(database).await;
7485 database
7486 .assign_execution_intent_nonce(intent.id, 7)
7487 .await
7488 .unwrap();
7489 let intent = database.get_execution_intent(intent.id).await.unwrap();
7490 let (tx_hash, raw_transaction) = expected_swap_tx(expected_min_amount_out(50)).await;
7491 let mut raw_transaction = hex::decode(raw_transaction.strip_prefix("0x").unwrap()).unwrap();
7492 raw_transaction.push(0xff);
7493 persist_test_payload(database, keys, &intent, tx_hash, &raw_transaction).await;
7494 database
7495 .record_execution_status(
7496 intent.id,
7497 &tx_hash.to_string(),
7498 TransactionStatus::Broadcast,
7499 None,
7500 None,
7501 None,
7502 None,
7503 None,
7504 )
7505 .await
7506 .unwrap();
7507
7508 (intent, tx_hash)
7509 }
7510
7511 async fn persist_test_swap_broadcast(
7512 database: &BlockchainCacheDatabase,
7513 keys: Option<&PayloadKeySet>,
7514 ) -> (ExecutionIntentRow, B256, Vec<u8>) {
7515 let intent = reserve_test_swap_intent(database).await;
7516 database
7517 .assign_execution_intent_nonce(intent.id, 7)
7518 .await
7519 .unwrap();
7520 let intent = database.get_execution_intent(intent.id).await.unwrap();
7521 let (tx_hash, raw_transaction) = expected_swap_tx(expected_min_amount_out(50)).await;
7522 let raw_transaction = hex::decode(raw_transaction.strip_prefix("0x").unwrap()).unwrap();
7523 persist_test_payload(database, keys, &intent, tx_hash, &raw_transaction).await;
7524 database
7525 .record_execution_status(
7526 intent.id,
7527 &tx_hash.to_string(),
7528 TransactionStatus::Broadcast,
7529 None,
7530 None,
7531 None,
7532 None,
7533 None,
7534 )
7535 .await
7536 .unwrap();
7537 (intent, tx_hash, raw_transaction)
7538 }
7539
7540 async fn persist_test_payload(
7541 database: &BlockchainCacheDatabase,
7542 keys: Option<&PayloadKeySet>,
7543 intent: &ExecutionIntentRow,
7544 tx_hash: B256,
7545 raw_transaction: &[u8],
7546 ) {
7547 if let Some(keys) = keys {
7548 database.reserve_execution_payload_seal(keys).await.unwrap();
7549 let transaction_hash = tx_hash.to_string();
7550 let context =
7551 payload_context_identity(intent, &transaction_hash, 42_161, keys.deployment_id())
7552 .unwrap();
7553 let envelope = keys.seal(raw_transaction, &context).unwrap();
7554 database
7555 .add_execution_transaction_envelope(intent.id, 42_161, &transaction_hash, &envelope)
7556 .await
7557 .unwrap();
7558 } else {
7559 database
7560 .add_execution_transaction_hash(
7561 intent.id,
7562 42_161,
7563 &tx_hash.to_string(),
7564 raw_transaction,
7565 )
7566 .await
7567 .unwrap();
7568 }
7569 }
7570
7571 async fn later_reconnect(
7572 previous: BlockchainExecutionClient,
7573 http_rpc_url: String,
7574 ) -> anyhow::Error {
7575 let database = previous.cache.database.as_ref().unwrap().clone();
7576 let payload_keys = previous.payload_keys.clone();
7577 drop(previous);
7578 let mut next = test_client(http_rpc_url);
7579 next.cache.database = Some(database);
7580 next.payload_keys = payload_keys;
7581 next.signer = Some(Arc::new(
7582 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7583 ));
7584 next.reconcile_unresolved_execution().await.unwrap_err()
7585 }
7586
7587 fn start_with_events(
7588 client: &mut BlockchainExecutionClient,
7589 ) -> tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent> {
7590 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
7591 replace_exec_event_sender(sender);
7592 client.start().unwrap();
7593 receiver
7594 }
7595
7596 async fn await_pending_tasks(client: &BlockchainExecutionClient) {
7597 tokio::time::timeout(TEST_TIMEOUT, async {
7598 while !client.pending_tasks.all_finished() {
7599 tokio::time::sleep(Duration::from_millis(1)).await;
7600 }
7601 })
7602 .await
7603 .unwrap();
7604 }
7605
7606 fn collect_order_events(
7607 receiver: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
7608 ) -> Vec<OrderEventAny> {
7609 let mut events = Vec::new();
7610
7611 while let Ok(event) = receiver.try_recv() {
7612 if let ExecutionEvent::Order(order_event) = event {
7613 events.push(order_event);
7614 }
7615 }
7616 events
7617 }
7618
7619 fn assert_swap_quarantined_without_terminal_event(events: &[OrderEventAny]) {
7620 assert_eq!(events.len(), 1, "was: {events:?}");
7621 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
7622 }
7623
7624 fn assert_swap_submitted_and_filled(events: &[OrderEventAny]) {
7625 assert_eq!(events.len(), 2, "was: {events:?}");
7626 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
7627 assert!(matches!(&events[1], OrderEventAny::Filled(_)));
7628 }
7629
7630 fn expected_swap_calldata(min_amount_out: U256) -> Vec<u8> {
7631 UniswapV3SwapRouter::exactInputSingleCall {
7632 params: UniswapV3SwapRouter::ExactInputSingleParams {
7633 tokenIn: WETH_ADDRESS,
7634 tokenOut: address!("af88d065e77c8cC2239327C5EDb3A432268e5831"),
7635 fee: U24::try_from(500u32).unwrap(),
7636 recipient: address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
7637 deadline: U256::from(FIXTURE_BLOCK_TIMESTAMP + 300),
7638 amountIn: U256::from(1_000_000_000_000_000u64),
7639 amountOutMinimum: min_amount_out,
7640 sqrtPriceLimitX96: U160::ZERO,
7641 },
7642 }
7643 .abi_encode()
7644 }
7645
7646 fn expected_min_amount_out(slippage_bps: u32) -> U256 {
7647 expected_min_amount_out_for(&test_pool(), true, slippage_bps)
7648 }
7649
7650 fn expected_min_amount_out_for(pool: &Pool, zero_for_one: bool, slippage_bps: u32) -> U256 {
7651 let profiler = test_profiler(pool, FIXTURE_BLOCK);
7652 let quote = profiler
7653 .swap_exact_in(U256::from(1_000_000_000_000_000u64), zero_for_one, None)
7654 .unwrap();
7655 let quoted = exact_output_amount("e, zero_for_one).unwrap();
7656 derive_min_amount_out(quoted, slippage_bps).unwrap()
7657 }
7658
7659 fn expected_sell_quote_amount() -> U256 {
7660 let profiler = test_profiler(&test_pool(), FIXTURE_BLOCK);
7661 let quote = profiler
7662 .swap_exact_in(U256::from(1_000_000_000_000_000u64), true, None)
7663 .unwrap();
7664 exact_output_amount("e, true).unwrap()
7665 }
7666
7667 fn quote_response(amount: U256) -> String {
7668 let result = (amount, U160::from(1u128 << 96), 0u32, U256::from(50_000u64)).abi_encode();
7669 serde_json::json!({
7670 "jsonrpc": "2.0",
7671 "id": 1,
7672 "result": hex::encode_prefixed(result),
7673 })
7674 .to_string()
7675 }
7676
7677 async fn expected_swap_tx(min_amount_out: U256) -> (B256, String) {
7678 let expected_tx = build_eip1559_transaction(
7681 42161,
7682 7,
7683 78_000,
7684 130_000_000,
7685 10_000_000,
7686 ROUTER_ADDRESS,
7687 U256::ZERO,
7688 Bytes::from(expected_swap_calldata(min_amount_out)),
7689 );
7690 sign_eip1559_transaction(
7691 expected_tx,
7692 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7693 )
7694 .await
7695 .map(|(hash, raw)| (hash, nautilus_core::hex::encode_prefixed(&raw)))
7696 .unwrap()
7697 }
7698
7699 fn expected_buy_base_amount() -> U256 {
7700 U256::from(1_000_000_000_000_000u64)
7701 }
7702
7703 fn expected_buy_amount_in() -> U256 {
7704 let profiler = test_profiler(&test_pool(), FIXTURE_BLOCK);
7705 profiler
7706 .swap_exact_out(expected_buy_base_amount(), false, None)
7707 .unwrap()
7708 .get_input_amount()
7709 }
7710
7711 fn expected_buy_min_amount_out(slippage_bps: u32) -> U256 {
7712 derive_min_amount_out(expected_buy_base_amount(), slippage_bps).unwrap()
7713 }
7714
7715 fn expected_buy_swap_calldata(min_amount_out: U256, amount_in: U256) -> Vec<u8> {
7716 UniswapV3SwapRouter::exactInputSingleCall {
7717 params: UniswapV3SwapRouter::ExactInputSingleParams {
7718 tokenIn: USDC_ADDRESS,
7719 tokenOut: WETH_ADDRESS,
7720 fee: U24::try_from(500u32).unwrap(),
7721 recipient: address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
7722 deadline: U256::from(FIXTURE_BLOCK_TIMESTAMP + 300),
7723 amountIn: amount_in,
7724 amountOutMinimum: min_amount_out,
7725 sqrtPriceLimitX96: U160::ZERO,
7726 },
7727 }
7728 .abi_encode()
7729 }
7730
7731 async fn expected_buy_swap_tx(min_amount_out: U256, amount_in: U256) -> (B256, String) {
7732 let expected_tx = build_eip1559_transaction(
7733 42161,
7734 7,
7735 78_000,
7736 130_000_000,
7737 10_000_000,
7738 ROUTER_ADDRESS,
7739 U256::ZERO,
7740 Bytes::from(expected_buy_swap_calldata(min_amount_out, amount_in)),
7741 );
7742 sign_eip1559_transaction(
7743 expected_tx,
7744 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
7745 )
7746 .await
7747 .map(|(hash, raw)| (hash, nautilus_core::hex::encode_prefixed(&raw)))
7748 .unwrap()
7749 }
7750
7751 fn finalized_buy_swap_receipt(tx_hash: B256, amount_in: U256) -> String {
7752 finalized_buy_swap_receipt_with_base_out(tx_hash, amount_in, expected_buy_base_amount())
7753 }
7754
7755 fn finalized_buy_swap_receipt_with_base_out(
7756 tx_hash: B256,
7757 amount_in: U256,
7758 base_out: U256,
7759 ) -> String {
7760 let data = (
7761 -I256::try_from(u128::try_from(base_out).unwrap()).unwrap(),
7762 I256::try_from(u128::try_from(amount_in).unwrap()).unwrap(),
7763 U160::from(1_u128 << 96),
7764 TEST_LIQUIDITY,
7765 I24::try_from(0).unwrap(),
7766 )
7767 .abi_encode();
7768 serde_json::json!({
7769 "jsonrpc": "2.0",
7770 "id": 1,
7771 "result": {
7772 "transactionHash": tx_hash.to_string(),
7773 "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7774 "blockNumber": "0x1cf0d41",
7775 "transactionIndex": "0x2",
7776 "gasUsed": "0xc3c0",
7777 "effectiveGasPrice": "0x5f5e100",
7778 "status": "0x1",
7779 "logs": [{
7780 "removed": false,
7781 "logIndex": "0x6",
7782 "transactionIndex": "0x2",
7783 "transactionHash": tx_hash.to_string(),
7784 "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7785 "blockNumber": "0x1cf0d41",
7786 "address": test_pool().address.to_string(),
7787 "data": hex::encode_prefixed(data),
7788 "topics": [
7789 "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67",
7790 "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266",
7791 "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266"
7792 ]
7793 }]
7794 }
7795 })
7796 .to_string()
7797 }
7798
7799 fn finalized_buy_swap_block(tx_hash: B256, min_amount_out: U256, amount_in: U256) -> String {
7800 serde_json::json!({
7801 "jsonrpc": "2.0",
7802 "id": 1,
7803 "result": {
7804 "number": "0x1cf0d41",
7805 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7806 "parentHash": FIXTURE_BLOCK_HASH,
7807 "timestamp": "0x69044a21",
7808 "baseFeePerGas": "0x5f5e100",
7809 "transactions": [{
7810 "hash": tx_hash.to_string(),
7811 "from": WALLET,
7812 "nonce": "0x7",
7813 "chainId": "0xa4b1",
7814 "type": "0x2",
7815 "to": ROUTER,
7816 "input": hex::encode_prefixed(expected_buy_swap_calldata(min_amount_out, amount_in)),
7817 "value": "0x0",
7818 "gas": "0x130b0",
7819 "maxFeePerGas": "0x7bfa480",
7820 "maxPriorityFeePerGas": "0x989680"
7821 }]
7822 }
7823 })
7824 .to_string()
7825 }
7826
7827 fn finalized_buy_swap_rpc_state(
7828 tx_hash: B256,
7829 min_amount_out: U256,
7830 amount_in: U256,
7831 ) -> MockRpcState {
7832 let receipt = finalized_buy_swap_receipt(tx_hash, amount_in);
7833 let block = finalized_buy_swap_block(tx_hash, min_amount_out, amount_in);
7834 with_finalized_identity(
7835 signing_rpc_state()
7836 .with_response("eth_getTransactionReceipt", &receipt)
7837 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
7838 .with_send_raw_transaction_echo()
7839 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE)
7840 .with_call_response(ALLOWANCE_SELECTOR, CALL_ALLOWANCE),
7841 &block,
7842 &receipt,
7843 )
7844 }
7845
7846 fn finalized_swap_receipt(tx_hash: B256) -> String {
7847 let data = (
7848 I256::try_from(1_000_000_000_000_000_i128).unwrap(),
7849 I256::try_from(-1_000_000_i128).unwrap(),
7850 U160::from(1_u128 << 96),
7851 TEST_LIQUIDITY,
7852 I24::try_from(0).unwrap(),
7853 )
7854 .abi_encode();
7855 serde_json::json!({
7856 "jsonrpc": "2.0",
7857 "id": 1,
7858 "result": {
7859 "transactionHash": tx_hash.to_string(),
7860 "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7861 "blockNumber": "0x1cf0d41",
7862 "transactionIndex": "0x2",
7863 "gasUsed": "0xc3c0",
7864 "effectiveGasPrice": "0x5f5e100",
7865 "status": "0x1",
7866 "logs": [{
7867 "removed": false,
7868 "logIndex": "0x6",
7869 "transactionIndex": "0x2",
7870 "transactionHash": tx_hash.to_string(),
7871 "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7872 "blockNumber": "0x1cf0d41",
7873 "address": test_pool().address.to_string(),
7874 "data": hex::encode_prefixed(data),
7875 "topics": [
7876 "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67",
7877 "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266",
7878 "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266"
7879 ]
7880 }]
7881 }
7882 })
7883 .to_string()
7884 }
7885
7886 fn finalized_swap_receipt_with_unrelated_swap(tx_hash: B256) -> String {
7887 let mut receipt: serde_json::Value =
7888 serde_json::from_str(&finalized_swap_receipt(tx_hash)).unwrap();
7889 let logs = receipt["result"]["logs"].as_array_mut().unwrap();
7890 let mut unrelated = logs[0].clone();
7891 unrelated["address"] = serde_json::json!(ROUTER);
7892 unrelated["logIndex"] = serde_json::json!("0x7");
7893 logs.push(unrelated);
7894 receipt.to_string()
7895 }
7896
7897 fn finalized_swap_block(tx_hash: B256, min_amount_out: U256) -> String {
7898 serde_json::json!({
7899 "jsonrpc": "2.0",
7900 "id": 1,
7901 "result": {
7902 "number": "0x1cf0d41",
7903 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
7904 "parentHash": FIXTURE_BLOCK_HASH,
7905 "timestamp": "0x69044a21",
7906 "baseFeePerGas": "0x5f5e100",
7907 "transactions": [{
7908 "hash": tx_hash.to_string(),
7909 "from": WALLET,
7910 "nonce": "0x7",
7911 "chainId": "0xa4b1",
7912 "type": "0x2",
7913 "to": ROUTER,
7914 "input": hex::encode_prefixed(expected_swap_calldata(min_amount_out)),
7915 "value": "0x0",
7916 "gas": "0x130b0",
7917 "maxFeePerGas": "0x7bfa480",
7918 "maxPriorityFeePerGas": "0x989680"
7919 }]
7920 }
7921 })
7922 .to_string()
7923 }
7924
7925 fn finalized_swap_rpc_state(tx_hash: B256, min_amount_out: U256) -> MockRpcState {
7926 let receipt = finalized_swap_receipt(tx_hash);
7927 let block = finalized_swap_block(tx_hash, min_amount_out);
7928 with_finalized_identity(
7929 signing_rpc_state()
7930 .with_response("eth_getTransactionReceipt", &receipt)
7931 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
7932 .with_send_raw_transaction_echo()
7933 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE)
7934 .with_call_response(ALLOWANCE_SELECTOR, CALL_ALLOWANCE),
7935 &block,
7936 &receipt,
7937 )
7938 }
7939
7940 fn with_finalized_identity(
7941 state: MockRpcState,
7942 block_response: &str,
7943 receipt_response: &str,
7944 ) -> MockRpcState {
7945 let block: serde_json::Value = serde_json::from_str(block_response).unwrap();
7946 let receipt: serde_json::Value = serde_json::from_str(receipt_response).unwrap();
7947 let transaction = block["result"]["transactions"][0].clone();
7948 let transaction_response = serde_json::json!({
7949 "jsonrpc": "2.0",
7950 "id": 1,
7951 "result": transaction,
7952 })
7953 .to_string();
7954 let success = receipt["result"]["status"] == "0x1";
7955 let mut trace = serde_json::json!({
7956 "type": "CALL",
7957 "from": transaction["from"],
7958 "to": transaction["to"],
7959 "value": transaction["value"],
7960 "gas": transaction["gas"],
7961 "gasUsed": receipt["result"]["gasUsed"],
7962 "input": transaction["input"],
7963 "output": "0x",
7964 "calls": [],
7965 });
7966
7967 if !success {
7968 trace["error"] = serde_json::json!("execution reverted");
7969 }
7970 let trace_response = serde_json::json!({
7971 "jsonrpc": "2.0",
7972 "id": 1,
7973 "result": trace,
7974 })
7975 .to_string();
7976 state
7977 .with_response("eth_getTransactionByHash", &transaction_response)
7978 .with_response("debug_traceTransaction", &trace_response)
7979 }
7980
7981 fn receipt_with_transaction_hash(receipt_response: &str, tx_hash: B256) -> String {
7982 let mut receipt: serde_json::Value = serde_json::from_str(receipt_response).unwrap();
7983 receipt["result"]["transactionHash"] = serde_json::json!(tx_hash.to_string());
7984 receipt.to_string()
7985 }
7986
7987 fn replacement_head_block(tx_hash: B256) -> String {
7988 serde_json::json!({
7989 "jsonrpc": "2.0",
7990 "id": 1,
7991 "result": {
7992 "number": "0x1cf0d40",
7993 "hash": "0x1111111111111111111111111111111111111111111111111111111111111111",
7994 "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000001",
7995 "timestamp": "0x69044a20",
7996 "baseFeePerGas": "0x5f5e100",
7997 "transactions": [{
7998 "hash": "0x3333333333333333333333333333333333333333333333333333333333333333",
7999 "from": "0x0000000000000000000000000000000000000001",
8000 "nonce": "0x1",
8001 "type": "0x0",
8002 "to": WETH,
8003 "input": "0x",
8004 "value": "0x0",
8005 "gas": "0x5208"
8006 }, {
8007 "hash": tx_hash.to_string(),
8008 "from": WALLET,
8009 "nonce": "0x7",
8010 "chainId": "0xa4b1",
8011 "type": "0x2",
8012 "to": WETH,
8013 "input": "0xd0e30db0",
8014 "value": "0x38d7ea4c68000",
8015 "gas": "0x130b0",
8016 "maxFeePerGas": "0x7bfa480",
8017 "maxPriorityFeePerGas": "0x989680"
8018 }]
8019 }
8020 })
8021 .to_string()
8022 }
8023
8024 fn finalized_wrap_block(tx_hash: B256) -> String {
8025 serde_json::json!({
8026 "jsonrpc": "2.0",
8027 "id": 1,
8028 "result": {
8029 "number": "0x1cf0d41",
8030 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
8031 "parentHash": FIXTURE_BLOCK_HASH,
8032 "timestamp": "0x69044a21",
8033 "baseFeePerGas": "0x5f5e100",
8034 "transactions": [{
8035 "hash": tx_hash.to_string(),
8036 "from": WALLET,
8037 "nonce": "0x7",
8038 "chainId": "0xa4b1",
8039 "type": "0x2",
8040 "to": WETH,
8041 "input": "0xd0e30db0",
8042 "value": "0x38d7ea4c68000",
8043 "gas": "0x130b0",
8044 "maxFeePerGas": "0x7bfa480",
8045 "maxPriorityFeePerGas": "0x989680"
8046 }]
8047 }
8048 })
8049 .to_string()
8050 }
8051
8052 fn finalized_approve_block(tx_hash: B256, amount: U256) -> String {
8053 let calldata = ERC20::approveCall {
8054 spender: ROUTER_ADDRESS,
8055 amount,
8056 }
8057 .abi_encode();
8058 serde_json::json!({
8059 "jsonrpc": "2.0",
8060 "id": 1,
8061 "result": {
8062 "number": "0x1cf0d41",
8063 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
8064 "parentHash": FIXTURE_BLOCK_HASH,
8065 "timestamp": "0x69044a21",
8066 "baseFeePerGas": "0x5f5e100",
8067 "transactions": [{
8068 "hash": tx_hash.to_string(),
8069 "from": WALLET,
8070 "nonce": "0x7",
8071 "chainId": "0xa4b1",
8072 "type": "0x2",
8073 "to": WETH,
8074 "input": hex::encode_prefixed(calldata),
8075 "value": "0x0",
8076 "gas": "0x130b0",
8077 "maxFeePerGas": "0x7bfa480",
8078 "maxPriorityFeePerGas": "0x989680"
8079 }]
8080 }
8081 })
8082 .to_string()
8083 }
8084
8085 fn fixture_sell_plan() -> SwapPlan {
8086 let pool = test_pool();
8087 let order = test_market_sell_order(pool.instrument_id);
8088 let quote_token = pool.get_quote_token();
8089 SwapPlan {
8090 order,
8091 quote_currency: Currency::new_checked(
8092 "e_token.symbol,
8093 quote_token.decimals,
8094 0,
8095 "e_token.name,
8096 CurrencyType::Crypto,
8097 )
8098 .unwrap(),
8099 instrument_id: pool.instrument_id,
8100 pool_address: pool.address,
8101 router: ROUTER_ADDRESS,
8102 factory: UNISWAP_V3.dex.factory,
8103 weth: WETH_ADDRESS,
8104 token_in: WETH_ADDRESS,
8105 token_out: USDC_ADDRESS,
8106 fee: U24::try_from(500u32).unwrap(),
8107 amount_in: U256::from(1_000_000_000_000_000u64),
8108 min_amount_out: expected_min_amount_out(50),
8109 slippage_bps: 50,
8110 quote_spend_ceiling: None,
8111 profiler_position: Some(profiler_event_position()),
8112 pool,
8113 }
8114 }
8115
8116 fn fixture_block_response(number: u64, hash: B256) -> String {
8117 let parent_hash = if number == FIXTURE_BLOCK {
8118 B256::from_str("0x0000000000000000000000000000000000000000000000000000000000000001")
8119 .unwrap()
8120 } else {
8121 B256::from_str(FIXTURE_BLOCK_HASH).unwrap()
8122 };
8123 serde_json::json!({
8124 "jsonrpc": "2.0",
8125 "id": 1,
8126 "result": {
8127 "number": format!("0x{number:x}"),
8128 "hash": hash.to_string(),
8129 "parentHash": parent_hash.to_string(),
8130 "timestamp": format!("0x{:x}", FIXTURE_BLOCK_TIMESTAMP + number - FIXTURE_BLOCK),
8131 "baseFeePerGas": "0x5f5e100",
8132 "transactions": []
8133 }
8134 })
8135 .to_string()
8136 }
8137
8138 fn profiler_event_receipt(pool_address: Address) -> String {
8139 let transaction_hash = B256::from([0x33; 32]);
8140 serde_json::json!({
8141 "jsonrpc": "2.0",
8142 "id": 1,
8143 "result": {
8144 "transactionHash": transaction_hash.to_string(),
8145 "blockHash": FIXTURE_BLOCK_HASH,
8146 "blockNumber": "0x1cf0d40",
8147 "transactionIndex": "0x2",
8148 "gasUsed": "0xc3c0",
8149 "effectiveGasPrice": "0x5f5e100",
8150 "status": "0x1",
8151 "logs": [{
8152 "removed": false,
8153 "logIndex": "0x6",
8154 "transactionIndex": "0x2",
8155 "transactionHash": transaction_hash.to_string(),
8156 "blockHash": FIXTURE_BLOCK_HASH,
8157 "blockNumber": "0x1cf0d40",
8158 "address": pool_address.to_string(),
8159 "data": "0x",
8160 "topics": [test_pool().dex.swap_created_event.as_ref()]
8161 }]
8162 }
8163 })
8164 .to_string()
8165 }
8166
8167 fn profiler_event_position() -> BlockPosition {
8168 BlockPosition::new(FIXTURE_BLOCK, B256::from([0x33; 32]).to_string(), 2, 6)
8169 .with_block_hash(Some(FIXTURE_BLOCK_HASH.to_string()))
8170 }
8171
8172 #[rstest]
8173 fn quantity_to_raw_amount_scales_by_token_decimals() {
8174 assert_eq!(
8175 quantity_to_raw_amount(Quantity::from("0.001"), 18).unwrap(),
8176 U256::from(1_000_000_000_000_000u64)
8177 );
8178 assert_eq!(
8179 quantity_to_raw_amount(Quantity::from("1.5"), 18).unwrap(),
8180 U256::from(1_500_000_000_000_000_000u128)
8181 );
8182 assert_eq!(
8183 quantity_to_raw_amount(Quantity::from("12.5"), 6).unwrap(),
8184 U256::from(12_500_000u64)
8185 );
8186 }
8187
8188 #[rstest]
8189 fn quantity_to_raw_amount_uses_defi_quantity_precision() {
8190 let amount = U256::from(10_000_000_000_000_000u64);
8191 let quantity = Quantity::from_u256(amount, 18).unwrap();
8192
8193 assert_eq!(quantity_to_raw_amount(quantity, 18).unwrap(), amount);
8194 }
8195
8196 #[rstest]
8197 fn quantity_to_raw_amount_rejects_zero() {
8198 let error = quantity_to_raw_amount(Quantity::from("0.0"), 18).unwrap_err();
8199
8200 assert_eq!(error.to_string(), "Order quantity must be positive");
8201 }
8202
8203 #[rstest]
8204 fn quantity_to_raw_amount_rejects_inexact_token_units() {
8205 let error = quantity_to_raw_amount(Quantity::from("0.0000001"), 6).unwrap_err();
8206
8207 assert!(
8208 error
8209 .to_string()
8210 .contains("is not exactly representable in 6 base token decimals"),
8211 "was: {error}"
8212 );
8213 }
8214
8215 #[rstest]
8216 fn raw_amount_to_quantity_inverts_base_quantity() {
8217 let quantity = Quantity::from("0.001");
8218 let amount = quantity_to_raw_amount(quantity, 18).unwrap();
8219
8220 assert_eq!(raw_amount_to_quantity(amount, 18).unwrap(), quantity);
8221 }
8222
8223 #[rstest]
8224 fn raw_amount_to_quantity_rejects_positive_amount_truncated_to_zero() {
8225 let error = raw_amount_to_quantity(U256::from(99u64), 18).unwrap_err();
8226
8227 assert!(
8228 error
8229 .to_string()
8230 .contains("is below representable quantity precision"),
8231 "was: {error}"
8232 );
8233 }
8234
8235 #[rstest]
8236 fn fill_price_from_quote_recovers_spent_quote_after_quantity_truncation() {
8237 let last_qty = raw_amount_to_quantity(U256::from(1_000_000_403_079_044u64), 18).unwrap();
8238 let quote_amount = U256::from(1_891_348u64);
8239 let quote = Currency::new_checked("USDC", 6, 0, "USD Coin", CurrencyType::Crypto).unwrap();
8240 let last_px = fill_price_from_quote(last_qty, quote_amount, quote).unwrap();
8241
8242 assert_eq!(
8243 Money::from_decimal(last_qty.as_decimal() * last_px.as_decimal(), quote).unwrap(),
8244 Money::from_u256(quote_amount, quote).unwrap()
8245 );
8246 }
8247
8248 #[rstest]
8249 fn swap_token_pair_is_directional() {
8250 assert_eq!(
8251 swap_token_pair(OrderSide::Sell, WETH_ADDRESS, USDC_ADDRESS).unwrap(),
8252 (WETH_ADDRESS, USDC_ADDRESS)
8253 );
8254 assert_eq!(
8255 swap_token_pair(OrderSide::Buy, WETH_ADDRESS, USDC_ADDRESS).unwrap(),
8256 (USDC_ADDRESS, WETH_ADDRESS)
8257 );
8258 }
8259
8260 #[rstest]
8261 fn restore_swap_plan_buy_uses_quote_input() {
8262 let (client, cache) =
8263 swap_client_with_cache(buy_test_config("http://127.0.0.1:1".to_string()));
8264 let order = test_market_buy_order(test_pool().instrument_id);
8265 cache
8266 .borrow_mut()
8267 .add_order(order.clone(), None, None, true)
8268 .unwrap();
8269 let amount_in = U256::from(2_345_678u64);
8270 let intent = ExecutionIntentRow {
8271 id: 7,
8272 schema_version: crate::execution::transaction::EXECUTION_SCHEMA_VERSION,
8273 chain_id: 42161,
8274 wallet_address: WALLET.to_string(),
8275 nonce: Some(7),
8276 purpose: "swap".to_string(),
8277 status: "finalized".to_string(),
8278 client_order_id: Some(order.client_order_id().to_string()),
8279 trader_id: Some(order.trader_id().to_string()),
8280 strategy_id: Some(order.strategy_id().to_string()),
8281 account_id: Some("BLOCKCHAIN-001".to_string()),
8282 instrument_id: Some(order.instrument_id().to_string()),
8283 pool_address: Some(test_pool().address.to_string()),
8284 transaction_to: ROUTER.to_string(),
8285 transaction_input: "0x".to_string(),
8286 transaction_value: "0".to_string(),
8287 amount_in: Some(amount_in.to_string()),
8288 created_block: FIXTURE_BLOCK,
8289 acknowledgement_emitted: true,
8290 fill_emitted: false,
8291 terminal_emitted: false,
8292 active: true,
8293 };
8294
8295 let plan = client.restore_swap_plan(&intent).unwrap();
8296
8297 assert_eq!(plan.token_in, USDC_ADDRESS);
8298 assert_eq!(plan.token_out, WETH_ADDRESS);
8299 assert_eq!(plan.amount_in, amount_in);
8300 }
8301
8302 #[rstest]
8303 #[case(1_000_000, 50, 995_000)]
8304 #[case(1_000_000, 0, 1_000_000)]
8305 #[case(1_000_000, 200, 980_000)]
8306 #[case(10_000, 9_999, 1)]
8307 fn derive_min_amount_out_applies_slippage(
8308 #[case] quoted: u64,
8309 #[case] slippage_bps: u32,
8310 #[case] expected: u64,
8311 ) {
8312 assert_eq!(
8313 derive_min_amount_out(U256::from(quoted), slippage_bps).unwrap(),
8314 U256::from(expected)
8315 );
8316 }
8317
8318 #[rstest]
8319 fn derive_min_amount_out_rejects_zero_result() {
8320 let error = derive_min_amount_out(U256::from(9_999u64), 9_999).unwrap_err();
8321
8322 assert!(
8323 error.to_string().contains("Derived minimum output is zero"),
8324 "was: {error}"
8325 );
8326 }
8327
8328 #[rstest]
8329 fn derive_min_amount_out_rejects_full_slippage() {
8330 let error = derive_min_amount_out(U256::from(1_000_000u64), 10_000).unwrap_err();
8331
8332 assert!(
8333 error.to_string().contains("must be below 10000"),
8334 "was: {error}"
8335 );
8336 }
8337
8338 #[rstest]
8339 fn replacement_scan_range_is_bounded_and_checked() {
8340 assert_eq!(
8341 replacement_scan_range(10, 10).unwrap(),
8342 RangeInclusive::new(10, 10)
8343 );
8344 assert_eq!(
8345 replacement_scan_range(10, 10 + MAX_REPLACEMENT_SCAN_BLOCKS).unwrap(),
8346 RangeInclusive::new(10, 10 + MAX_REPLACEMENT_SCAN_BLOCKS - 1)
8347 );
8348 assert!(
8349 replacement_scan_range(11, 10)
8350 .unwrap_err()
8351 .to_string()
8352 .contains("is behind execution creation block")
8353 );
8354 assert_eq!(
8355 replacement_scan_range(u64::MAX - 1, u64::MAX).unwrap(),
8356 RangeInclusive::new(u64::MAX - 1, u64::MAX)
8357 );
8358 }
8359
8360 #[rstest]
8361 fn terminal_execution_event_ids_are_stable_and_kind_specific() {
8362 let transaction_hash = B256::from([0x42; 32]);
8363
8364 let fill = execution_event_id(transaction_hash, b"fill");
8365 let fill_retry = execution_event_id(transaction_hash, b"fill");
8366 let reverted = execution_event_id(transaction_hash, b"reverted");
8367
8368 assert_eq!(fill, fill_retry);
8369 assert_ne!(fill, reverted);
8370 }
8371
8372 #[rstest]
8373 fn prepared_transaction_debug_redacts_raw_transaction() {
8374 let prepared = PreparedTransaction {
8375 intent_id: 1,
8376 created_block: 2,
8377 nonce: 3,
8378 tx_hash: B256::ZERO,
8379 raw_tx: vec![0xde, 0xad, 0xbe, 0xef],
8380 payload_lease: None,
8381 };
8382
8383 let debug = format!("{prepared:?}");
8384
8385 assert!(debug.contains("raw_tx: \"<redacted>\""));
8386 assert!(!debug.contains("[222, 173, 190, 239]"));
8387 }
8388
8389 #[rstest]
8390 fn call_trace_rejects_unreviewed_internal_edge() {
8391 let signed = crate::execution::transaction::DecodedSignedTransaction {
8392 hash: B256::from([1; 32]),
8393 signer: Address::from_str(WALLET).unwrap(),
8394 chain_id: 42_161,
8395 nonce: 7,
8396 to: ROUTER_ADDRESS,
8397 value: U256::ZERO,
8398 input: Bytes::from(expected_swap_calldata(expected_min_amount_out(50))),
8399 gas_limit: 78_000,
8400 max_fee_per_gas: 130_000_000,
8401 max_priority_fee_per_gas: 10_000_000,
8402 };
8403 let input_digest = keccak256(&signed.input);
8404 let trace = VerifiedCallTrace {
8405 call_type: RpcCallType::Call,
8406 from: signed.signer,
8407 to: Some(signed.to),
8408 value: signed.value,
8409 input_selector: signed
8410 .input
8411 .get(..4)
8412 .map(|selector| selector.try_into().unwrap()),
8413 input_digest,
8414 success: true,
8415 calls: vec![VerifiedCallTrace {
8416 call_type: RpcCallType::Call,
8417 from: ROUTER_ADDRESS,
8418 to: Some(WETH_ADDRESS),
8419 value: U256::ZERO,
8420 input_selector: None,
8421 input_digest: B256::ZERO,
8422 success: true,
8423 calls: Vec::new(),
8424 }],
8425 };
8426 let manifest = &test_config("http://127.0.0.1:1".to_string())
8427 .verification
8428 .unwrap()
8429 .deployment_manifest;
8430
8431 let error = validate_call_trace(&trace, &signed, true, "swap_sell", manifest).unwrap_err();
8432
8433 assert!(
8434 error.to_string().contains("unreviewed call edge"),
8435 "was: {error}"
8436 );
8437 }
8438
8439 #[rstest]
8440 fn call_trace_rejects_unlisted_precompile_target() {
8441 let manifest = test_config("http://127.0.0.1:1".to_string())
8442 .verification
8443 .unwrap()
8444 .deployment_manifest;
8445 let calls = [VerifiedCallTrace {
8446 call_type: RpcCallType::Call,
8447 from: ROUTER_ADDRESS,
8448 to: Some(address!("0000000000000000000000000000000000000007")),
8449 value: U256::ZERO,
8450 input_selector: None,
8451 input_digest: B256::ZERO,
8452 success: true,
8453 calls: Vec::new(),
8454 }];
8455
8456 let error =
8457 validate_internal_calls(&calls, ROUTER_ADDRESS, "swap_sell", &manifest).unwrap_err();
8458
8459 assert_eq!(
8460 error.to_string(),
8461 format!(
8462 "Verified call trace contains an unreviewed call edge {ROUTER_ADDRESS} -> {} for swap_sell",
8463 address!("0000000000000000000000000000000000000007")
8464 )
8465 );
8466 }
8467
8468 #[rstest]
8469 fn call_trace_requires_exact_call_type() {
8470 let manifest = test_config("http://127.0.0.1:1".to_string())
8471 .verification
8472 .unwrap()
8473 .deployment_manifest;
8474 let calls = [VerifiedCallTrace {
8475 call_type: RpcCallType::Staticcall,
8476 from: ROUTER_ADDRESS,
8477 to: Some(test_pool().address),
8478 value: U256::ZERO,
8479 input_selector: None,
8480 input_digest: B256::ZERO,
8481 success: true,
8482 calls: Vec::new(),
8483 }];
8484
8485 let error =
8486 validate_internal_calls(&calls, ROUTER_ADDRESS, "swap_sell", &manifest).unwrap_err();
8487
8488 assert!(
8489 error.to_string().contains("unreviewed staticcall edge"),
8490 "was: {error}"
8491 );
8492 }
8493
8494 #[rstest]
8495 fn call_trace_requires_exact_caller() {
8496 let manifest = test_config("http://127.0.0.1:1".to_string())
8497 .verification
8498 .unwrap()
8499 .deployment_manifest;
8500 let calls = [VerifiedCallTrace {
8501 call_type: RpcCallType::Call,
8502 from: WETH_ADDRESS,
8503 to: Some(test_pool().address),
8504 value: U256::ZERO,
8505 input_selector: None,
8506 input_digest: B256::ZERO,
8507 success: true,
8508 calls: Vec::new(),
8509 }];
8510
8511 let error =
8512 validate_internal_calls(&calls, ROUTER_ADDRESS, "swap_sell", &manifest).unwrap_err();
8513
8514 assert_eq!(
8515 error.to_string(),
8516 "Verified call trace child has an invalid caller context"
8517 );
8518 }
8519
8520 #[rstest]
8521 fn call_trace_rejects_contract_creation() {
8522 let signed = crate::execution::transaction::DecodedSignedTransaction {
8523 hash: B256::from([1; 32]),
8524 signer: Address::from_str(WALLET).unwrap(),
8525 chain_id: 42_161,
8526 nonce: 7,
8527 to: ROUTER_ADDRESS,
8528 value: U256::ZERO,
8529 input: Bytes::from(expected_swap_calldata(expected_min_amount_out(50))),
8530 gas_limit: 78_000,
8531 max_fee_per_gas: 130_000_000,
8532 max_priority_fee_per_gas: 10_000_000,
8533 };
8534 let trace = VerifiedCallTrace {
8535 call_type: RpcCallType::Call,
8536 from: signed.signer,
8537 to: Some(signed.to),
8538 value: signed.value,
8539 input_selector: signed
8540 .input
8541 .get(..4)
8542 .map(|selector| selector.try_into().unwrap()),
8543 input_digest: keccak256(&signed.input),
8544 success: true,
8545 calls: vec![VerifiedCallTrace {
8546 call_type: RpcCallType::Create,
8547 from: ROUTER_ADDRESS,
8548 to: Some(address!("0000000000000000000000000000000000000007")),
8549 value: U256::ZERO,
8550 input_selector: None,
8551 input_digest: B256::ZERO,
8552 success: true,
8553 calls: Vec::new(),
8554 }],
8555 };
8556 let manifest = &test_config("http://127.0.0.1:1".to_string())
8557 .verification
8558 .unwrap()
8559 .deployment_manifest;
8560
8561 let error = validate_call_trace(&trace, &signed, true, "swap_sell", manifest).unwrap_err();
8562
8563 assert!(
8564 error
8565 .to_string()
8566 .contains("forbidden state-changing operation"),
8567 "was: {error}"
8568 );
8569 }
8570
8571 #[tokio::test]
8572 async fn swap_quote_rejects_missing_ingestion_block_hash() {
8573 let (client, state) = client_with_mock_rpc(execution_rpc_state()).await;
8574 let plan = fixture_sell_plan();
8575 let position = BlockPosition::new(
8576 FIXTURE_BLOCK,
8577 FIXTURE_BLOCK_HASH.to_string(),
8578 BLOCK_SCOPED_SNAPSHOT_INDEX,
8579 BLOCK_SCOPED_SNAPSHOT_INDEX,
8580 );
8581
8582 let error = validate_swap_quote(
8583 &position,
8584 &plan,
8585 100,
8586 &client.verification,
8587 &client
8588 .config
8589 .verification
8590 .as_ref()
8591 .unwrap()
8592 .deployment_manifest,
8593 )
8594 .await
8595 .unwrap_err();
8596
8597 assert!(
8598 error.to_string().contains("no ingestion-time block hash"),
8599 "was: {error}"
8600 );
8601 assert!(state.recorded_requests().is_empty());
8602 }
8603
8604 #[tokio::test]
8605 async fn swap_quote_rejects_replaced_ingestion_block() {
8606 let changed = fixture_block_response(FIXTURE_BLOCK, B256::from([0x44; 32]));
8607 let state = execution_rpc_state().with_parameter_response(
8608 "eth_getBlockByNumber",
8609 "0x1cf0d40",
8610 &changed,
8611 );
8612 let (client, _) = client_with_mock_rpc(state).await;
8613 let plan = fixture_sell_plan();
8614 let position = BlockPosition::new(
8615 FIXTURE_BLOCK,
8616 FIXTURE_BLOCK_HASH.to_string(),
8617 BLOCK_SCOPED_SNAPSHOT_INDEX,
8618 BLOCK_SCOPED_SNAPSHOT_INDEX,
8619 )
8620 .with_block_hash(Some(FIXTURE_BLOCK_HASH.to_string()));
8621
8622 let error = validate_swap_quote(
8623 &position,
8624 &plan,
8625 100,
8626 &client.verification,
8627 &client
8628 .config
8629 .verification
8630 .as_ref()
8631 .unwrap()
8632 .deployment_manifest,
8633 )
8634 .await
8635 .unwrap_err();
8636
8637 assert!(error.to_string().contains("changed from"), "was: {error}");
8638 }
8639
8640 #[tokio::test]
8641 async fn swap_quote_accepts_exact_canonical_event_watermark() {
8642 let pool = test_pool();
8643 let receipt = profiler_event_receipt(pool.address);
8644 let state = execution_rpc_state().with_response("eth_getTransactionReceipt", &receipt);
8645 let (client, _) = client_with_mock_rpc(state).await;
8646 let plan = fixture_sell_plan();
8647
8648 let anchors = validate_swap_quote(
8649 &profiler_event_position(),
8650 &plan,
8651 100,
8652 &client.verification,
8653 &client
8654 .config
8655 .verification
8656 .as_ref()
8657 .unwrap()
8658 .deployment_manifest,
8659 )
8660 .await
8661 .unwrap();
8662
8663 assert_eq!(anchors.watermark.number, profiler_event_position().number);
8664 assert_eq!(anchors.state.number, FIXTURE_BLOCK);
8665 assert_eq!(anchors.state.hash, B256::from([0x11; 32]));
8666 assert_eq!(anchors.state.timestamp, FIXTURE_BLOCK_TIMESTAMP);
8667 }
8668
8669 #[tokio::test]
8670 async fn swap_quote_rejects_mismatched_receipt_position() {
8671 let pool = test_pool();
8672 let mut receipt: serde_json::Value =
8673 serde_json::from_str(&profiler_event_receipt(pool.address)).unwrap();
8674 receipt["result"]["transactionIndex"] = serde_json::json!("0x3");
8675 let state =
8676 execution_rpc_state().with_response("eth_getTransactionReceipt", &receipt.to_string());
8677 let (client, _) = client_with_mock_rpc(state).await;
8678 let plan = fixture_sell_plan();
8679
8680 let error = validate_swap_quote(
8681 &profiler_event_position(),
8682 &plan,
8683 100,
8684 &client.verification,
8685 &client
8686 .config
8687 .verification
8688 .as_ref()
8689 .unwrap()
8690 .deployment_manifest,
8691 )
8692 .await
8693 .unwrap_err();
8694
8695 assert_eq!(
8696 error.to_string(),
8697 "Profiler receipt position does not match its ingestion watermark"
8698 );
8699 }
8700
8701 #[tokio::test]
8702 async fn swap_quote_rejects_watermark_from_different_pool() {
8703 let receipt = profiler_event_receipt(ROUTER_ADDRESS);
8704 let state = execution_rpc_state().with_response("eth_getTransactionReceipt", &receipt);
8705 let (client, _) = client_with_mock_rpc(state).await;
8706 let plan = fixture_sell_plan();
8707
8708 let error = validate_swap_quote(
8709 &profiler_event_position(),
8710 &plan,
8711 100,
8712 &client.verification,
8713 &client
8714 .config
8715 .verification
8716 .as_ref()
8717 .unwrap()
8718 .deployment_manifest,
8719 )
8720 .await
8721 .unwrap_err();
8722
8723 assert!(
8724 error
8725 .to_string()
8726 .contains("did not come from expected pool"),
8727 "was: {error}"
8728 );
8729 }
8730
8731 #[rstest]
8732 fn verified_sell_quote_sets_signed_amounts() {
8733 let plan = fixture_sell_plan();
8734 let quote = UniswapV3Quote {
8735 amount: expected_sell_quote_amount(),
8736 sqrt_price_x96_after: U160::from(1u128 << 96),
8737 initialized_ticks_crossed: 0,
8738 gas_estimate: U256::from(50_000u64),
8739 };
8740
8741 let (amount_in, min_amount_out) = verified_swap_amounts(&plan, quote).unwrap();
8742
8743 assert_eq!(amount_in, U256::from(1_000_000_000_000_000u64));
8744 assert_eq!(min_amount_out, expected_min_amount_out(50));
8745 }
8746
8747 #[rstest]
8748 fn exact_output_amount_extracts_negative_leg() {
8749 let pool = test_pool();
8750 let profiler = test_profiler(&pool, FIXTURE_BLOCK);
8751 let quote = profiler
8752 .swap_exact_in(U256::from(1_000_000_000_000_000u64), true, None)
8753 .unwrap();
8754
8755 let amount = exact_output_amount("e, true).unwrap();
8756
8757 assert!(amount < U256::from(1_000_000_000_000_000u64));
8758 assert!(amount > U256::from(990_000_000_000_000u64));
8759
8760 let error = exact_output_amount("e, false).unwrap_err();
8761 assert!(
8762 error.to_string().contains("is not a positive output"),
8763 "was: {error}"
8764 );
8765 }
8766
8767 #[rstest]
8768 #[case("0xC6962004f452bE9203591991D15f6b388e09E8D0.Arbitrum:UniswapV3", true)]
8769 #[case("0xC6962004f452bE9203591991D15f6b388e09E8D0.Ethereum:UniswapV3", false)]
8770 #[case("0xC6962004f452bE9203591991D15f6b388e09E8D0.Arbitrum:UniswapV4", false)]
8771 #[case("ETHUSDT-PERP.BINANCE", false)]
8772 fn handles_order_venue_matches_chain_and_dex(#[case] instrument: &str, #[case] expected: bool) {
8773 let client = test_client("http://127.0.0.1:1".to_string());
8774 let instrument_id: InstrumentId = instrument.parse().unwrap();
8775
8776 assert_eq!(client.handles_order_venue(instrument_id.venue), expected);
8777 }
8778
8779 #[tokio::test]
8780 async fn submit_order_denies_buy_without_allowlisted_pair() {
8781 let (mut client, cache) =
8782 swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
8783 let pool = test_pool();
8784 let order = test_market_buy_order(pool.instrument_id);
8785 cache
8786 .borrow_mut()
8787 .add_order(order.clone(), None, None, true)
8788 .unwrap();
8789 let mut receiver = start_with_events(&mut client);
8790
8791 client.submit_order(submit_order_cmd(&order)).unwrap();
8792
8793 let events = collect_order_events(&mut receiver);
8794 assert_eq!(events.len(), 1);
8795 let OrderEventAny::Denied(denied) = &events[0] else {
8796 panic!("expected OrderDenied, was {:?}", events[0]);
8797 };
8798 assert!(
8799 denied
8800 .reason
8801 .contains("not in the `allowed_token_pairs` allowlist"),
8802 "was: {}",
8803 denied.reason
8804 );
8805 assert!(
8806 denied.reason.contains(&USDC_ADDRESS.to_string()),
8807 "was: {}",
8808 denied.reason
8809 );
8810 }
8811
8812 #[tokio::test]
8813 async fn submit_order_denies_buy_quote_denominated_quantity() {
8814 let (mut client, cache) =
8815 swap_client_with_cache(buy_test_config("http://127.0.0.1:1".to_string()));
8816 let pool = test_pool();
8817 let order = OrderTestBuilder::new(OrderType::Market)
8818 .trader_id(TraderId::from("TRADER-001"))
8819 .strategy_id(StrategyId::from("S-001"))
8820 .instrument_id(pool.instrument_id)
8821 .client_order_id(ClientOrderId::from("O-SWAP-BUY-001"))
8822 .side(OrderSide::Buy)
8823 .quantity(Quantity::from("0.001"))
8824 .quote_quantity(true)
8825 .build();
8826 cache
8827 .borrow_mut()
8828 .add_order(order.clone(), None, None, true)
8829 .unwrap();
8830 let mut receiver = start_with_events(&mut client);
8831
8832 client.submit_order(submit_order_cmd(&order)).unwrap();
8833
8834 let events = collect_order_events(&mut receiver);
8835 assert_eq!(events.len(), 1);
8836 let OrderEventAny::Denied(denied) = &events[0] else {
8837 panic!("expected OrderDenied, was {:?}", events[0]);
8838 };
8839 assert!(
8840 denied.reason.contains("Quote-denominated"),
8841 "was: {}",
8842 denied.reason
8843 );
8844 }
8845
8846 #[tokio::test]
8847 async fn submit_order_denies_buy_amount_above_max_order_amount() {
8848 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
8849 config.max_order_amount = Some(999_999_999_999_999);
8850 let (mut client, cache) = swap_client_with_cache(config);
8851 let order = test_market_buy_order(test_pool().instrument_id);
8852 cache
8853 .borrow_mut()
8854 .add_order(order.clone(), None, None, true)
8855 .unwrap();
8856 let mut receiver = start_with_events(&mut client);
8857
8858 client.submit_order(submit_order_cmd(&order)).unwrap();
8859
8860 let events = collect_order_events(&mut receiver);
8861 assert_eq!(events.len(), 1);
8862 let OrderEventAny::Denied(denied) = &events[0] else {
8863 panic!("expected OrderDenied, was {:?}", events[0]);
8864 };
8865 assert!(
8866 denied
8867 .reason
8868 .contains("exceeds the configured `max_order_amount`"),
8869 "was: {}",
8870 denied.reason
8871 );
8872 }
8873
8874 #[tokio::test]
8875 async fn submit_order_denies_buy_without_quote_spend_limit() {
8876 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
8877 config.quote_spend_limits = None;
8878 let (mut client, cache) = swap_client_with_cache(config);
8879 let order = test_market_buy_order(test_pool().instrument_id);
8880 cache
8881 .borrow_mut()
8882 .add_order(order.clone(), None, None, true)
8883 .unwrap();
8884 let mut receiver = start_with_events(&mut client);
8885
8886 client.submit_order(submit_order_cmd(&order)).unwrap();
8887
8888 let events = collect_order_events(&mut receiver);
8889 assert_eq!(events.len(), 1);
8890 let OrderEventAny::Denied(denied) = &events[0] else {
8891 panic!("expected OrderDenied, was {:?}", events[0]);
8892 };
8893 assert!(
8894 denied
8895 .reason
8896 .contains("No `quote_spend_limits` entry for BUY token pair"),
8897 "was: {}",
8898 denied.reason
8899 );
8900 }
8901
8902 #[tokio::test]
8903 async fn submit_order_uses_pair_specific_quote_spend_limit() {
8904 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
8905 config.quote_spend_limits = Some(vec![quote_spend_limit(
8906 WETH,
8907 USDC,
8908 18,
8909 "1000000000000000000",
8910 )]);
8911 let (mut client, cache) = swap_client_with_cache(config);
8912 let order = test_market_buy_order(test_pool().instrument_id);
8913 cache
8914 .borrow_mut()
8915 .add_order(order.clone(), None, None, true)
8916 .unwrap();
8917 let mut receiver = start_with_events(&mut client);
8918
8919 client.submit_order(submit_order_cmd(&order)).unwrap();
8920
8921 let events = collect_order_events(&mut receiver);
8922 assert_eq!(events.len(), 1);
8923 let OrderEventAny::Denied(denied) = &events[0] else {
8924 panic!("expected OrderDenied, was {:?}", events[0]);
8925 };
8926 assert!(
8927 denied
8928 .reason
8929 .contains("No `quote_spend_limits` entry for BUY token pair"),
8930 "was: {}",
8931 denied.reason
8932 );
8933 }
8934
8935 #[tokio::test]
8936 async fn submit_order_denies_buy_with_quote_spend_precision_mismatch() {
8937 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
8938 config.quote_spend_limits.as_mut().unwrap()[0].spend_token_decimals = 18;
8939 let error = test_client_result(config, test_pool()).unwrap_err();
8940 assert!(
8941 error
8942 .to_string()
8943 .contains("Quote spend limit decimals do not match the deployment manifest"),
8944 "was: {error}"
8945 );
8946 }
8947
8948 #[tokio::test]
8949 async fn submit_order_denies_buy_with_zero_quote_spend_limit() {
8950 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
8951 config.quote_spend_limits.as_mut().unwrap()[0].max_amount = "0".to_string();
8952 let (mut client, cache) = swap_client_with_cache(config);
8953 let order = test_market_buy_order(test_pool().instrument_id);
8954 cache
8955 .borrow_mut()
8956 .add_order(order.clone(), None, None, true)
8957 .unwrap();
8958 let mut receiver = start_with_events(&mut client);
8959
8960 client.submit_order(submit_order_cmd(&order)).unwrap();
8961
8962 let events = collect_order_events(&mut receiver);
8963 assert_eq!(events.len(), 1);
8964 let OrderEventAny::Denied(denied) = &events[0] else {
8965 panic!("expected OrderDenied, was {:?}", events[0]);
8966 };
8967 assert!(
8968 denied
8969 .reason
8970 .contains("exceeds the configured `quote_spend_limits` maximum 0"),
8971 "was: {}",
8972 denied.reason
8973 );
8974 }
8975
8976 #[tokio::test]
8977 async fn submit_order_denies_buy_one_raw_unit_above_quote_spend_limit_before_readiness() {
8978 let amount_in = expected_buy_amount_in();
8979 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
8980 config.quote_spend_limits.as_mut().unwrap()[0].max_amount =
8981 (amount_in - U256::from(1u8)).to_string();
8982 let (mut client, cache) = swap_client_with_cache(config);
8983 let order = test_market_buy_order(test_pool().instrument_id);
8984 cache
8985 .borrow_mut()
8986 .add_order(order.clone(), None, None, true)
8987 .unwrap();
8988 let mut receiver = start_with_events(&mut client);
8989
8990 client.submit_order(submit_order_cmd(&order)).unwrap();
8991
8992 let events = collect_order_events(&mut receiver);
8993 assert_eq!(events.len(), 1);
8994 let OrderEventAny::Denied(denied) = &events[0] else {
8995 panic!("expected OrderDenied, was {:?}", events[0]);
8996 };
8997 assert!(
8998 denied.reason.contains(&format!(
8999 "BUY quote amount {amount_in} exceeds the configured `quote_spend_limits`"
9000 )),
9001 "was: {}",
9002 denied.reason
9003 );
9004 assert!(!client.core.is_connected());
9005 assert!(!client.cache.has_database());
9006 assert!(client.signer.is_none());
9007 assert!(client.pending_tasks.is_empty());
9008 }
9009
9010 #[tokio::test]
9011 async fn submit_order_denies_non_market_order_type() {
9012 let (mut client, cache) =
9013 swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9014 let pool = test_pool();
9015 let order = OrderTestBuilder::new(OrderType::Limit)
9016 .trader_id(TraderId::from("TRADER-001"))
9017 .strategy_id(StrategyId::from("S-001"))
9018 .instrument_id(pool.instrument_id)
9019 .client_order_id(ClientOrderId::from("O-SWAP-001"))
9020 .side(OrderSide::Sell)
9021 .quantity(Quantity::from("0.001"))
9022 .price(Price::from("2000"))
9023 .build();
9024 cache
9025 .borrow_mut()
9026 .add_order(order.clone(), None, None, true)
9027 .unwrap();
9028 let mut receiver = start_with_events(&mut client);
9029
9030 client.submit_order(submit_order_cmd(&order)).unwrap();
9031
9032 let events = collect_order_events(&mut receiver);
9033 assert_eq!(events.len(), 1);
9034 let OrderEventAny::Denied(denied) = &events[0] else {
9035 panic!("expected OrderDenied, was {:?}", events[0]);
9036 };
9037 assert!(
9038 denied.reason.contains("only Market is supported"),
9039 "was: {}",
9040 denied.reason
9041 );
9042 }
9043
9044 #[tokio::test]
9045 async fn submit_order_denies_quote_denominated_quantity() {
9046 let (mut client, cache) =
9047 swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9048 let pool = test_pool();
9049 let order = OrderTestBuilder::new(OrderType::Market)
9050 .trader_id(TraderId::from("TRADER-001"))
9051 .strategy_id(StrategyId::from("S-001"))
9052 .instrument_id(pool.instrument_id)
9053 .client_order_id(ClientOrderId::from("O-SWAP-001"))
9054 .side(OrderSide::Sell)
9055 .quantity(Quantity::from("0.001"))
9056 .quote_quantity(true)
9057 .build();
9058 cache
9059 .borrow_mut()
9060 .add_order(order.clone(), None, None, true)
9061 .unwrap();
9062 let mut receiver = start_with_events(&mut client);
9063
9064 client.submit_order(submit_order_cmd(&order)).unwrap();
9065
9066 let events = collect_order_events(&mut receiver);
9067 assert_eq!(events.len(), 1);
9068 let OrderEventAny::Denied(denied) = &events[0] else {
9069 panic!("expected OrderDenied, was {:?}", events[0]);
9070 };
9071 assert!(
9072 denied.reason.contains("Quote-denominated"),
9073 "was: {}",
9074 denied.reason
9075 );
9076 }
9077
9078 #[tokio::test]
9079 async fn submit_order_denies_unknown_pool() {
9080 let (mut client, cache) =
9081 swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9082 let unknown: InstrumentId = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45.Arbitrum:UniswapV3"
9083 .parse()
9084 .unwrap();
9085 let order = OrderTestBuilder::new(OrderType::Market)
9086 .trader_id(TraderId::from("TRADER-001"))
9087 .strategy_id(StrategyId::from("S-001"))
9088 .instrument_id(unknown)
9089 .client_order_id(ClientOrderId::from("O-SWAP-001"))
9090 .side(OrderSide::Sell)
9091 .quantity(Quantity::from("0.001"))
9092 .build();
9093 cache
9094 .borrow_mut()
9095 .add_order(order.clone(), None, None, true)
9096 .unwrap();
9097 let mut receiver = start_with_events(&mut client);
9098
9099 client.submit_order(submit_order_cmd(&order)).unwrap();
9100
9101 let events = collect_order_events(&mut receiver);
9102 assert_eq!(events.len(), 1);
9103 let OrderEventAny::Denied(denied) = &events[0] else {
9104 panic!("expected OrderDenied, was {:?}", events[0]);
9105 };
9106 assert!(
9107 denied.reason.contains("Unknown pool"),
9108 "was: {}",
9109 denied.reason
9110 );
9111 }
9112
9113 #[tokio::test]
9114 async fn submit_order_denies_sell_when_only_buy_pair_allowlisted() {
9115 let mut config = test_config("http://127.0.0.1:1".to_string());
9116 config.allowed_token_pairs = Some(vec![(USDC.to_string(), WETH.to_string())]);
9117 let (mut client, _) = swap_client_with_cache(config);
9118 let order = test_market_sell_order(test_pool().instrument_id);
9119 let mut receiver = start_with_events(&mut client);
9120
9121 client.submit_order(submit_order_cmd(&order)).unwrap();
9122
9123 let events = collect_order_events(&mut receiver);
9124 assert_eq!(events.len(), 1);
9125 let OrderEventAny::Denied(denied) = &events[0] else {
9126 panic!("expected OrderDenied, was {:?}", events[0]);
9127 };
9128 assert!(
9129 denied
9130 .reason
9131 .contains("not in the `allowed_token_pairs` allowlist"),
9132 "was: {}",
9133 denied.reason
9134 );
9135 assert!(
9136 denied.reason.contains(&WETH_ADDRESS.to_string()),
9137 "was: {}",
9138 denied.reason
9139 );
9140 }
9141
9142 #[tokio::test]
9143 async fn submit_order_sell_ignores_quote_spend_limits() {
9144 let mut config = test_config("http://127.0.0.1:1".to_string());
9145 config.quote_spend_limits = Some(vec![quote_spend_limit(WETH, USDC, 18, "0")]);
9146 let (mut client, _) = swap_client_with_cache(config);
9147 let order = test_market_sell_order(test_pool().instrument_id);
9148 let mut receiver = start_with_events(&mut client);
9149
9150 client.submit_order(submit_order_cmd(&order)).unwrap();
9151
9152 let events = collect_order_events(&mut receiver);
9153 assert_eq!(events.len(), 1);
9154 let OrderEventAny::Denied(denied) = &events[0] else {
9155 panic!("expected OrderDenied, was {:?}", events[0]);
9156 };
9157 assert!(
9158 denied
9159 .reason
9160 .contains("Blockchain execution client is not connected"),
9161 "was: {}",
9162 denied.reason
9163 );
9164 }
9165
9166 #[tokio::test]
9167 async fn submit_order_denies_token_pair_outside_allowlist() {
9168 let mut config = test_config("http://127.0.0.1:1".to_string());
9169 config.allowed_token_pairs = Some(Vec::new());
9170 let (mut client, _) = swap_client_with_cache(config);
9171 let order = test_market_sell_order(test_pool().instrument_id);
9172 let mut receiver = start_with_events(&mut client);
9173
9174 client.submit_order(submit_order_cmd(&order)).unwrap();
9175
9176 let events = collect_order_events(&mut receiver);
9177 assert_eq!(events.len(), 1);
9178 let OrderEventAny::Denied(denied) = &events[0] else {
9179 panic!("expected OrderDenied, was {:?}", events[0]);
9180 };
9181 assert!(
9182 denied
9183 .reason
9184 .contains("not in the `allowed_token_pairs` allowlist"),
9185 "was: {}",
9186 denied.reason
9187 );
9188 }
9189
9190 #[tokio::test]
9191 async fn submit_order_denies_amount_above_max_order_amount() {
9192 let mut config = test_config("http://127.0.0.1:1".to_string());
9193 config.max_order_amount = Some(999_999_999_999_999); let (mut client, _) = swap_client_with_cache(config);
9195 let order = test_market_sell_order(test_pool().instrument_id);
9196 let mut receiver = start_with_events(&mut client);
9197
9198 client.submit_order(submit_order_cmd(&order)).unwrap();
9199
9200 let events = collect_order_events(&mut receiver);
9201 assert_eq!(events.len(), 1);
9202 let OrderEventAny::Denied(denied) = &events[0] else {
9203 panic!("expected OrderDenied, was {:?}", events[0]);
9204 };
9205 assert!(
9206 denied
9207 .reason
9208 .contains("exceeds the configured `max_order_amount`"),
9209 "was: {}",
9210 denied.reason
9211 );
9212 }
9213
9214 #[tokio::test]
9215 async fn submit_order_denies_slippage_param_above_ceiling() {
9216 let (mut client, _) = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9217 let order = test_market_sell_order(test_pool().instrument_id);
9218 let mut cmd = submit_order_cmd(&order);
9219 cmd.params = Some(serde_json::from_str(r#"{"slippage_bps": 201}"#).unwrap());
9220 let mut receiver = start_with_events(&mut client);
9221
9222 client.submit_order(cmd).unwrap();
9223
9224 let events = collect_order_events(&mut receiver);
9225 assert_eq!(events.len(), 1);
9226 let OrderEventAny::Denied(denied) = &events[0] else {
9227 panic!("expected OrderDenied, was {:?}", events[0]);
9228 };
9229 assert!(
9230 denied
9231 .reason
9232 .contains("exceeds the configured `max_slippage_bps`"),
9233 "was: {}",
9234 denied.reason
9235 );
9236 }
9237
9238 #[tokio::test]
9239 async fn submit_order_denies_pool_without_fee_tier() {
9240 let addr = start_mock_rpc_server(MockRpcState::default()).await;
9241 let mut pool = test_pool();
9242 pool.fee = None;
9243 let cache = Rc::new(RefCell::new(Cache::default()));
9244 cache.borrow_mut().add_pool(pool.clone()).unwrap();
9245 let order = test_market_sell_order(pool.instrument_id);
9246 cache
9247 .borrow_mut()
9248 .add_order(order.clone(), None, None, false)
9249 .unwrap();
9250 let core = ExecutionClientCore::new(
9251 TraderId::from("TRADER-001"),
9252 ClientId::from("BLOCKCHAIN-001"),
9253 *BLOCKCHAIN_VENUE,
9254 OmsType::Netting,
9255 AccountId::from("BLOCKCHAIN-001"),
9256 AccountType::Wallet,
9257 None,
9258 cache,
9259 );
9260 let mut client =
9261 BlockchainExecutionClient::new(core, test_config(format!("http://{addr}"))).unwrap();
9262 let mut receiver = start_with_events(&mut client);
9263
9264 client.submit_order(submit_order_cmd(&order)).unwrap();
9265
9266 let events = collect_order_events(&mut receiver);
9267 assert_eq!(events.len(), 1);
9268 let OrderEventAny::Denied(denied) = &events[0] else {
9269 panic!("expected OrderDenied, was {:?}", events[0]);
9270 };
9271 assert!(
9272 denied.reason.contains("no fee tier"),
9273 "was: {}",
9274 denied.reason
9275 );
9276 }
9277
9278 #[tokio::test]
9279 async fn submit_order_denies_without_live_profiler() {
9280 let addr = start_mock_rpc_server(MockRpcState::default()).await;
9281 let cache = Rc::new(RefCell::new(Cache::default()));
9282 let pool = test_pool();
9283 cache.borrow_mut().add_pool(pool.clone()).unwrap();
9284 let order = test_market_sell_order(pool.instrument_id);
9285 cache
9286 .borrow_mut()
9287 .add_order(order.clone(), None, None, false)
9288 .unwrap();
9289 let core = ExecutionClientCore::new(
9290 TraderId::from("TRADER-001"),
9291 ClientId::from("BLOCKCHAIN-001"),
9292 *BLOCKCHAIN_VENUE,
9293 OmsType::Netting,
9294 AccountId::from("BLOCKCHAIN-001"),
9295 AccountType::Wallet,
9296 None,
9297 cache,
9298 );
9299 let mut client =
9300 BlockchainExecutionClient::new(core, test_config(format!("http://{addr}"))).unwrap();
9301 let mut receiver = start_with_events(&mut client);
9302
9303 client.submit_order(submit_order_cmd(&order)).unwrap();
9304
9305 let events = collect_order_events(&mut receiver);
9306 assert_eq!(events.len(), 1);
9307 let OrderEventAny::Denied(denied) = &events[0] else {
9308 panic!("expected OrderDenied, was {:?}", events[0]);
9309 };
9310 assert!(
9311 denied.reason.contains("No pool profiler"),
9312 "was: {}",
9313 denied.reason
9314 );
9315 }
9316
9317 #[tokio::test]
9318 async fn submit_order_denies_when_not_connected() {
9319 let (mut client, _) = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9320 let order = test_market_sell_order(test_pool().instrument_id);
9321 let mut receiver = start_with_events(&mut client);
9322
9323 client.submit_order(submit_order_cmd(&order)).unwrap();
9324
9325 let events = collect_order_events(&mut receiver);
9326 assert_eq!(events.len(), 1);
9327 let OrderEventAny::Denied(denied) = &events[0] else {
9328 panic!("expected OrderDenied, was {:?}", events[0]);
9329 };
9330 assert!(
9331 denied.reason.contains("is not connected"),
9332 "was: {}",
9333 denied.reason
9334 );
9335 }
9336
9337 #[tokio::test]
9338 async fn submit_order_denies_without_durable_store() {
9339 let (mut client, _) = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9340 client.core.set_connected();
9341 let order = test_market_sell_order(test_pool().instrument_id);
9342 let mut receiver = start_with_events(&mut client);
9343
9344 client.submit_order(submit_order_cmd(&order)).unwrap();
9345
9346 let events = collect_order_events(&mut receiver);
9347 assert_eq!(events.len(), 1);
9348 let OrderEventAny::Denied(denied) = &events[0] else {
9349 panic!("expected OrderDenied, was {:?}", events[0]);
9350 };
9351 assert!(
9352 denied.reason.contains("No durable store configured"),
9353 "was: {}",
9354 denied.reason
9355 );
9356 }
9357
9358 #[tokio::test]
9359 async fn submit_order_denies_when_transaction_in_flight() {
9360 let (mut client, _) = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string()));
9361 client.core.set_connected();
9362 *client.in_flight.lock() = Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
9363 intent_id: 1,
9364 nonce: 7,
9365 tx_hash: B256::ZERO,
9366 purpose: TransactionPurpose::Wrap,
9367 }));
9368 let order = test_market_sell_order(test_pool().instrument_id);
9369 let mut receiver = start_with_events(&mut client);
9370
9371 client.submit_order(submit_order_cmd(&order)).unwrap();
9372
9373 let events = collect_order_events(&mut receiver);
9374 assert_eq!(events.len(), 1);
9375 let OrderEventAny::Denied(denied) = &events[0] else {
9376 panic!("expected OrderDenied, was {:?}", events[0]);
9377 };
9378 assert!(
9379 denied.reason.contains("still awaiting finality"),
9380 "was: {}",
9381 denied.reason
9382 );
9383 }
9384
9385 #[tokio::test]
9386 async fn submit_order_denies_without_signer() {
9387 let Some((admin_pool, schema, mut client, _, _)) =
9388 swap_client_with_database("execution_submit_no_signer_test", swap_rpc_state().await)
9389 .await
9390 else {
9391 return;
9392 };
9393 client.signer = None;
9394 let order = test_market_sell_order(test_pool().instrument_id);
9395 let mut receiver = start_with_events(&mut client);
9396
9397 client.submit_order(submit_order_cmd(&order)).unwrap();
9398
9399 let events = collect_order_events(&mut receiver);
9400 assert_eq!(events.len(), 1);
9401 let OrderEventAny::Denied(denied) = &events[0] else {
9402 panic!("expected OrderDenied, was {:?}", events[0]);
9403 };
9404 assert!(
9405 denied.reason.contains("Signer not initialized"),
9406 "was: {}",
9407 denied.reason
9408 );
9409
9410 drop_execution_schema(&admin_pool, &schema).await;
9411 }
9412
9413 #[tokio::test]
9414 async fn submit_order_broadcasts_swap_and_records_client_order_id() {
9415 let Some((admin_pool, schema, mut client, state, _)) =
9416 swap_client_with_database("execution_submit_success_test", swap_rpc_state().await)
9417 .await
9418 else {
9419 return;
9420 };
9421 let order = test_market_sell_order(test_pool().instrument_id);
9422 let mut receiver = start_with_events(&mut client);
9423 let expected_min_out = expected_min_amount_out(50);
9424
9425 client.submit_order(submit_order_cmd(&order)).unwrap();
9426 await_pending_tasks(&client).await;
9427
9428 let events = collect_order_events(&mut receiver);
9429 assert_swap_submitted_and_filled(&events);
9430 let OrderEventAny::Submitted(submitted) = &events[0] else {
9431 panic!("expected OrderSubmitted, was {:?}", events[0]);
9432 };
9433 assert_eq!(submitted.client_order_id, order.client_order_id());
9434
9435 let (expected_hash, expected_raw) = expected_swap_tx(expected_min_out).await;
9437 let broadcasts: Vec<_> = state
9438 .recorded_requests()
9439 .into_iter()
9440 .filter(|request| request["method"] == "eth_sendRawTransaction")
9441 .collect();
9442 assert_eq!(broadcasts.len(), 1);
9443 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
9444
9445 let record = client
9446 .cache
9447 .get_execution_transaction(42161, &expected_hash.to_string())
9448 .await
9449 .unwrap()
9450 .unwrap();
9451 assert_eq!(record.nonce, 7);
9452 assert_eq!(record.purpose, "swap");
9453 assert_eq!(record.status, "finalized");
9454 assert_eq!(
9455 record.client_order_id.as_deref(),
9456 Some(order.client_order_id().as_str())
9457 );
9458 assert!(client.in_flight.lock().is_none());
9459 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
9460 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
9461 )))
9462 .fetch_one(&admin_pool)
9463 .await
9464 .unwrap();
9465 let decision_count = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(format!(
9466 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
9467 WHERE outcome = 'verified'"
9468 )))
9469 .fetch_one(&admin_pool)
9470 .await
9471 .unwrap();
9472 let connect_decision_count = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(format!(
9473 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
9474 WHERE decision_class = 'connect' AND outcome = 'verified'"
9475 )))
9476 .fetch_one(&admin_pool)
9477 .await
9478 .unwrap();
9479 assert_eq!(nonce_state, (8, 1));
9480 assert_eq!(decision_count, 35);
9481 assert_eq!(connect_decision_count, 1);
9482
9483 drop_execution_schema(&admin_pool, &schema).await;
9484 }
9485
9486 #[tokio::test]
9487 async fn submit_order_pins_every_pre_sign_state_read_to_swap_anchor() {
9488 let Some((admin_pool, schema, mut client, state, _)) =
9489 swap_client_with_database("execution_submit_anchor_reads_test", swap_rpc_state().await)
9490 .await
9491 else {
9492 return;
9493 };
9494 let order = test_market_sell_order(test_pool().instrument_id);
9495 let mut receiver = start_with_events(&mut client);
9496
9497 client.submit_order(submit_order_cmd(&order)).unwrap();
9498 await_pending_tasks(&client).await;
9499
9500 let events = collect_order_events(&mut receiver);
9501 assert_swap_submitted_and_filled(&events);
9502 let requests = state.recorded_requests();
9503 let broadcast_index = requests
9504 .iter()
9505 .position(|request| request["method"] == "eth_sendRawTransaction")
9506 .unwrap();
9507 let pre_broadcast = &requests[..broadcast_index];
9508 let latest_reads: Vec<_> = pre_broadcast
9509 .iter()
9510 .filter(|request| {
9511 request["method"] == "eth_getBlockByNumber" && request["params"][0] == "latest"
9512 })
9513 .collect();
9514 assert_eq!(latest_reads.len(), 3);
9515 assert!(
9516 latest_reads
9517 .iter()
9518 .all(|request| request["params"] == serde_json::json!(["latest", false]))
9519 );
9520
9521 for method in [
9522 "eth_getCode",
9523 "eth_call",
9524 "eth_estimateGas",
9525 "eth_getBalance",
9526 ] {
9527 let pinned: Vec<_> = pre_broadcast
9528 .iter()
9529 .filter(|request| request["method"] == method)
9530 .collect();
9531 assert!(!pinned.is_empty(), "method {method}");
9532 assert_eq!(pinned.len() % 3, 0, "method {method}: {pinned:?}");
9533 assert!(
9534 pinned
9535 .iter()
9536 .all(|request| request["params"][1] == FIXTURE_BLOCK_PARAM),
9537 "method {method}: {pinned:?}"
9538 );
9539 }
9540
9541 let numbered_blocks: Vec<_> = pre_broadcast
9542 .iter()
9543 .filter(|request| {
9544 request["method"] == "eth_getBlockByNumber"
9545 && request["params"][0] == FIXTURE_BLOCK_PARAM
9546 })
9547 .collect();
9548 assert!(!numbered_blocks.is_empty());
9549 assert_eq!(numbered_blocks.len() % 3, 0);
9550 assert!(
9551 numbered_blocks
9552 .iter()
9553 .all(|request| request["params"][1] == false)
9554 );
9555
9556 let chain_ids: Vec<_> = pre_broadcast
9557 .iter()
9558 .filter(|request| request["method"] == "eth_chainId")
9559 .collect();
9560 assert_eq!(chain_ids.len(), 3);
9561 assert!(
9562 chain_ids
9563 .iter()
9564 .all(|request| request["params"] == serde_json::json!([]))
9565 );
9566 let nonces: Vec<_> = pre_broadcast
9567 .iter()
9568 .filter(|request| request["method"] == "eth_getTransactionCount")
9569 .collect();
9570 assert_eq!(nonces.len(), 6);
9571 assert_eq!(
9572 nonces
9573 .iter()
9574 .filter(|request| {
9575 request["params"] == serde_json::json!([WALLET.to_ascii_lowercase(), "pending"])
9576 })
9577 .count(),
9578 3
9579 );
9580 assert_eq!(
9581 nonces
9582 .iter()
9583 .filter(|request| {
9584 request["params"]
9585 == serde_json::json!([WALLET.to_ascii_lowercase(), FIXTURE_BLOCK_PARAM])
9586 })
9587 .count(),
9588 3
9589 );
9590 let priority_fees: Vec<_> = pre_broadcast
9591 .iter()
9592 .filter(|request| request["method"] == "eth_maxPriorityFeePerGas")
9593 .collect();
9594 assert_eq!(priority_fees.len(), 3);
9595 assert!(
9596 priority_fees
9597 .iter()
9598 .all(|request| request["params"] == serde_json::json!([]))
9599 );
9600
9601 let (_, expected_raw) = expected_swap_tx(expected_min_amount_out(50)).await;
9602 assert_eq!(
9603 requests[broadcast_index]["params"][0].as_str().unwrap(),
9604 expected_raw
9605 );
9606 let created_block: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
9607 "SELECT created_block FROM {schema}.execution_intent"
9608 )))
9609 .fetch_one(&admin_pool)
9610 .await
9611 .unwrap();
9612 assert_eq!(created_block, i64::try_from(FIXTURE_BLOCK).unwrap());
9613
9614 drop_execution_schema(&admin_pool, &schema).await;
9615 }
9616
9617 #[tokio::test]
9618 async fn submit_order_denies_changed_swap_anchor_before_signing() {
9619 let canonical = fixture_block_response(FIXTURE_BLOCK, B256::from([0x11; 32]));
9620 let changed = fixture_block_response(FIXTURE_BLOCK, B256::from([0x44; 32]));
9621 let state = swap_rpc_state().await.with_parameter_response_sequence(
9622 "eth_getBlockByNumber",
9623 FIXTURE_BLOCK_PARAM,
9624 &[
9625 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9626 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9627 &canonical, &changed, &changed, &changed,
9628 ],
9629 );
9630 let Some((admin_pool, schema, mut client, state, _)) =
9631 swap_client_with_database("execution_submit_anchor_change_test", state).await
9632 else {
9633 return;
9634 };
9635 let order = test_market_sell_order(test_pool().instrument_id);
9636 let mut receiver = start_with_events(&mut client);
9637
9638 client.submit_order(submit_order_cmd(&order)).unwrap();
9639 await_pending_tasks(&client).await;
9640
9641 let events = collect_order_events(&mut receiver);
9642 assert_eq!(events.len(), 1, "was: {events:?}");
9643 let OrderEventAny::Denied(denied) = &events[0] else {
9644 panic!("expected OrderDenied, was {:?}", events[0]);
9645 };
9646 assert!(
9647 denied
9648 .reason
9649 .contains("pre-sign checkpoint reread verification disagreed"),
9650 "was: {}",
9651 denied.reason
9652 );
9653 assert_eq!(
9654 execution_intent_markers(&admin_pool, &schema).await,
9655 vec![("swap".into(), "recoverable".into(), false, false)]
9656 );
9657 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
9658 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
9659 )))
9660 .fetch_one(&admin_pool)
9661 .await
9662 .unwrap();
9663 assert_eq!(signed_count, 0);
9664 assert!(
9665 state
9666 .recorded_requests()
9667 .iter()
9668 .all(|request| { request["method"] != "eth_sendRawTransaction" })
9669 );
9670 assert!(client.in_flight.lock().is_none());
9671
9672 drop_execution_schema(&admin_pool, &schema).await;
9673 }
9674
9675 #[tokio::test]
9676 async fn submit_order_keeps_verified_swap_anchor_when_latest_advances() {
9677 let canonical = fixture_block_response(FIXTURE_BLOCK, B256::from([0x11; 32]));
9678 let newer = fixture_block_response(FIXTURE_BLOCK + 1, B256::from([0x44; 32]));
9679 let state = swap_rpc_state().await.with_parameter_response_sequence(
9680 "eth_getBlockByNumber",
9681 "latest",
9682 &[&canonical, &canonical, &canonical, &newer, &newer, &newer],
9683 );
9684 let Some((admin_pool, schema, mut client, state, _)) =
9685 swap_client_with_database("execution_submit_advancing_head_test", state).await
9686 else {
9687 return;
9688 };
9689 let order = test_market_sell_order(test_pool().instrument_id);
9690 let mut receiver = start_with_events(&mut client);
9691
9692 client.submit_order(submit_order_cmd(&order)).unwrap();
9693 await_pending_tasks(&client).await;
9694
9695 let events = collect_order_events(&mut receiver);
9696 assert_swap_submitted_and_filled(&events);
9697 let latest_reads = state
9698 .recorded_requests()
9699 .iter()
9700 .filter(|request| {
9701 request["method"] == "eth_getBlockByNumber" && request["params"][0] == "latest"
9702 })
9703 .count();
9704 assert_eq!(latest_reads, 3);
9705
9706 drop_execution_schema(&admin_pool, &schema).await;
9707 }
9708
9709 #[tokio::test]
9710 async fn submit_order_denies_changed_quote_watermark_before_signing() {
9711 let watermark_number = FIXTURE_BLOCK;
9712 let watermark_hash = B256::from([0x11; 32]);
9713 let canonical = fixture_block_response(watermark_number, watermark_hash);
9714 let changed = fixture_block_response(watermark_number, B256::from([0x66; 32]));
9715 let watermark_param = format!("0x{watermark_number:x}");
9716 let min_amount_out = expected_min_amount_out(50);
9717 let (tx_hash, _) = expected_swap_tx(min_amount_out).await;
9718 let head = finalized_swap_block(tx_hash, min_amount_out);
9719 let state = swap_rpc_state()
9720 .await
9721 .with_response("eth_getBlockByNumber", &head)
9722 .with_parameter_response_sequence(
9723 "eth_getBlockByNumber",
9724 &watermark_param,
9725 &[
9726 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9727 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9728 &changed, &changed, &changed,
9729 ],
9730 );
9731 let Some((admin_pool, schema, mut client, state, cache)) =
9732 swap_client_with_database("execution_submit_watermark_change_test", state).await
9733 else {
9734 return;
9735 };
9736 let pool = test_pool();
9737 cache
9738 .borrow_mut()
9739 .add_pool_profiler(test_profiler_at_block(
9740 &pool,
9741 watermark_number,
9742 &watermark_hash.to_string(),
9743 ))
9744 .unwrap();
9745 let order = test_market_sell_order(pool.instrument_id);
9746 let mut receiver = start_with_events(&mut client);
9747
9748 client.submit_order(submit_order_cmd(&order)).unwrap();
9749 await_pending_tasks(&client).await;
9750
9751 let events = collect_order_events(&mut receiver);
9752 assert_eq!(events.len(), 1, "was: {events:?}");
9753 let OrderEventAny::Denied(denied) = &events[0] else {
9754 panic!("expected OrderDenied, was {:?}", events[0]);
9755 };
9756 assert!(
9757 denied.reason.contains("changed before signing"),
9758 "was: {}",
9759 denied.reason
9760 );
9761 assert_eq!(
9762 execution_intent_markers(&admin_pool, &schema).await,
9763 vec![("swap".into(), "recoverable".into(), false, false)]
9764 );
9765 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
9766 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
9767 )))
9768 .fetch_one(&admin_pool)
9769 .await
9770 .unwrap();
9771 assert_eq!(signed_count, 0);
9772 assert!(
9773 state
9774 .recorded_requests()
9775 .iter()
9776 .all(|request| { request["method"] != "eth_sendRawTransaction" })
9777 );
9778 assert!(client.in_flight.lock().is_none());
9779
9780 drop_execution_schema(&admin_pool, &schema).await;
9781 }
9782
9783 #[tokio::test]
9784 async fn changed_swap_anchor_retains_ownership_when_recovery_commit_fails() {
9785 let canonical = fixture_block_response(FIXTURE_BLOCK, B256::from([0x11; 32]));
9786 let changed = fixture_block_response(FIXTURE_BLOCK, B256::from([0x44; 32]));
9787 let state = swap_rpc_state().await.with_parameter_response_sequence(
9788 "eth_getBlockByNumber",
9789 FIXTURE_BLOCK_PARAM,
9790 &[
9791 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9792 &canonical, &canonical, &canonical, &canonical, &canonical, &canonical, &canonical,
9793 &canonical, &changed, &changed, &changed,
9794 ],
9795 );
9796 let Some((admin_pool, schema, mut client, state, _)) =
9797 swap_client_with_database("execution_submit_anchor_recovery_fail_test", state).await
9798 else {
9799 return;
9800 };
9801 install_recoverable_commit_rejection(&admin_pool, &schema).await;
9802 let order = test_market_sell_order(test_pool().instrument_id);
9803 let mut receiver = start_with_events(&mut client);
9804
9805 client.submit_order(submit_order_cmd(&order)).unwrap();
9806 await_pending_tasks(&client).await;
9807
9808 let events = collect_order_events(&mut receiver);
9809 assert_eq!(events.len(), 1, "was: {events:?}");
9810 let OrderEventAny::Denied(denied) = &events[0] else {
9811 panic!("expected OrderDenied, was {:?}", events[0]);
9812 };
9813 assert!(
9814 denied
9815 .reason
9816 .contains("pre-sign checkpoint reread verification disagreed"),
9817 "was: {}",
9818 denied.reason
9819 );
9820 assert_eq!(
9821 execution_intent_markers(&admin_pool, &schema).await,
9822 vec![("swap".into(), "prepared".into(), false, true)]
9823 );
9824 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
9825 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
9826 )))
9827 .fetch_one(&admin_pool)
9828 .await
9829 .unwrap();
9830 assert_eq!(signed_count, 0);
9831 assert!(
9832 state
9833 .recorded_requests()
9834 .iter()
9835 .all(|request| { request["method"] != "eth_sendRawTransaction" })
9836 );
9837 assert!(matches!(
9838 *client.in_flight.lock(),
9839 Some(InFlightSlot::Preparing(TransactionPurpose::Swap))
9840 ));
9841
9842 drop_execution_schema(&admin_pool, &schema).await;
9843 }
9844
9845 #[tokio::test]
9846 async fn finalized_swap_emits_exact_fill_once_and_refreshes_wallet() {
9847 let min_amount_out = expected_min_amount_out(50);
9848 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
9849 let state = finalized_swap_rpc_state(expected_hash, min_amount_out);
9850 let Some((admin_pool, schema, mut client, state, _)) =
9851 swap_client_with_database("execution_submit_fill_test", state).await
9852 else {
9853 return;
9854 };
9855 let order = test_market_sell_order(test_pool().instrument_id);
9856 let mut receiver = start_with_events(&mut client);
9857
9858 let plan = client
9859 .prepare_swap(&submit_order_cmd(&order), &order)
9860 .unwrap();
9861 execute_swap(
9862 plan,
9863 client.transaction_executor().unwrap(),
9864 client.emitter.clone(),
9865 client.transaction_limits.max_quote_age_blocks,
9866 client.transaction_limits.deadline_seconds,
9867 )
9868 .await
9869 .unwrap();
9870
9871 let (nonce, wallet_address, transaction_to, transaction_input, transaction_value): (
9872 i64,
9873 String,
9874 String,
9875 String,
9876 String,
9877 ) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
9878 "SELECT nonce, wallet_address, transaction_to, transaction_input, transaction_value \
9879 FROM {schema}.execution_intent"
9880 )))
9881 .fetch_one(&admin_pool)
9882 .await
9883 .unwrap();
9884 let finalized_block = client
9885 .http_rpc_client
9886 .block_by_number(FIXTURE_BLOCK + 1, true)
9887 .await
9888 .unwrap();
9889 let finalized_transaction = finalized_block
9890 .transactions
9891 .iter()
9892 .find(|transaction| transaction.hash == expected_hash)
9893 .unwrap();
9894 assert_eq!(finalized_transaction.from.to_string(), wallet_address);
9895 assert_eq!(finalized_transaction.nonce, u64::try_from(nonce).unwrap());
9896 assert_eq!(
9897 finalized_transaction.to,
9898 Some(Address::from_str(&transaction_to).unwrap())
9899 );
9900 assert_eq!(
9901 finalized_transaction.input.as_ref(),
9902 hex::decode(transaction_input.strip_prefix("0x").unwrap()).unwrap()
9903 );
9904 assert_eq!(
9905 finalized_transaction.value,
9906 U256::from_str(&transaction_value).unwrap()
9907 );
9908
9909 let mut order_events = Vec::new();
9910 let mut account_states = Vec::new();
9911
9912 while let Ok(event) = receiver.try_recv() {
9913 match event {
9914 ExecutionEvent::Order(event) => order_events.push(event),
9915 ExecutionEvent::Account(state) => account_states.push(state),
9916 other => panic!("unexpected execution event: {other:?}"),
9917 }
9918 }
9919 assert_eq!(order_events.len(), 2, "was: {order_events:?}");
9920 assert!(matches!(&order_events[0], OrderEventAny::Submitted(_)));
9921 let OrderEventAny::Filled(fill) = &order_events[1] else {
9922 panic!("expected OrderFilled, was {:?}", order_events[1]);
9923 };
9924 let expected_commission = Money::from_u256(
9925 U256::from(50_112_u64) * U256::from(100_000_000_u64),
9926 test_pool().chain.native_currency(),
9927 )
9928 .unwrap();
9929 assert_eq!(fill.client_order_id, order.client_order_id());
9930 assert_eq!(fill.venue_order_id.as_str(), expected_hash.to_string());
9931 assert_eq!(fill.order_side, OrderSide::Sell);
9932 assert_eq!(fill.last_qty, Quantity::from("0.001"));
9933 assert_eq!(fill.last_px, Price::from("1000"));
9934 assert_eq!(fill.currency.code, "USDC");
9935 assert_eq!(fill.commission, Some(expected_commission));
9936 assert_eq!(fill.liquidity_side, LiquiditySide::Taker);
9937 assert_eq!(account_states.len(), 1);
9938 let account_state = &account_states[0];
9939 assert_eq!(account_state.account_id, AccountId::from("BLOCKCHAIN-001"));
9940 assert_eq!(account_state.account_type, AccountType::Wallet);
9941 assert_eq!(account_state.base_currency, None);
9942 assert_eq!(account_state.balances.len(), 3);
9943 assert!(account_state.margins.is_empty());
9944 assert!(account_state.is_reported);
9945 assert_eq!(
9946 account_state.balances,
9947 client.wallet_balance.lock().as_account_balances().unwrap()
9948 );
9949
9950 let (fill_emitted, active): (bool, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
9951 "SELECT fill_emitted, active FROM {schema}.execution_intent"
9952 )))
9953 .fetch_one(&admin_pool)
9954 .await
9955 .unwrap();
9956 assert!(fill_emitted);
9957 assert!(!active);
9958
9959 let database = client.cache.database.as_ref().unwrap().clone();
9960 let payload_keys = client.payload_keys.clone();
9961 let restart_config = client.config.clone();
9962 drop(client);
9963 let (mut restarted, _) = swap_client_with_cache(restart_config);
9964 restarted.cache.database = Some(database);
9965 restarted.payload_keys = payload_keys;
9966 restarted.signer = Some(Arc::new(
9967 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
9968 ));
9969 let mut restart_receiver = start_with_events(&mut restarted);
9970 restarted.reconcile_unresolved_execution().await.unwrap();
9971 restarted.reconcile_unresolved_execution().await.unwrap();
9972 assert!(collect_order_events(&mut restart_receiver).is_empty());
9973 let requests = state.recorded_requests();
9974 assert_eq!(
9975 requests
9976 .iter()
9977 .filter(|request| request["method"] == "eth_sendRawTransaction")
9978 .count(),
9979 1
9980 );
9981 assert_eq!(
9982 requests
9983 .iter()
9984 .filter(|request| {
9985 request["method"] == "eth_call"
9986 && request["params"][0]["data"]
9987 .as_str()
9988 .is_some_and(|data| data.starts_with(BALANCE_OF_SELECTOR))
9989 })
9990 .count(),
9991 9
9992 );
9993 assert_eq!(
9994 requests
9995 .iter()
9996 .filter(|request| request["method"] == "eth_getBalance")
9997 .count(),
9998 6
9999 );
10000
10001 drop_execution_schema(&admin_pool, &schema).await;
10002 }
10003
10004 #[tokio::test]
10005 async fn restart_emits_committed_swap_from_verified_inclusion_header() {
10006 let min_amount_out = expected_min_amount_out(50);
10007 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
10008 let state = finalized_swap_rpc_state(expected_hash, min_amount_out);
10009 let Some((admin_pool, schema, mut client, _, _)) =
10010 swap_client_with_database("execution_committed_fill_restart_test", state).await
10011 else {
10012 return;
10013 };
10014 let order = test_market_sell_order(test_pool().instrument_id);
10015 let mut receiver = start_with_events(&mut client);
10016 let plan = client
10017 .prepare_swap(&submit_order_cmd(&order), &order)
10018 .unwrap();
10019
10020 execute_swap(
10021 plan,
10022 client.transaction_executor().unwrap(),
10023 client.emitter.clone(),
10024 client.transaction_limits.max_quote_age_blocks,
10025 client.transaction_limits.deadline_seconds,
10026 )
10027 .await
10028 .unwrap();
10029 assert_swap_submitted_and_filled(&collect_order_events(&mut receiver));
10030 let ancestry_range: (i64, i64) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10031 "SELECT height_start, height_end FROM {schema}.execution_verification_decision \
10032 WHERE decision_class = 'finality' AND read_class = 'numbered_block' \
10033 AND height_end > height_start"
10034 )))
10035 .fetch_one(&admin_pool)
10036 .await
10037 .unwrap();
10038 assert_eq!(
10039 ancestry_range,
10040 ((FIXTURE_BLOCK + 1) as i64, (FIXTURE_BLOCK + 2) as i64)
10041 );
10042
10043 sqlx::query(sqlx::AssertSqlSafe(format!(
10044 "UPDATE {schema}.execution_intent SET fill_emitted = FALSE, active = TRUE"
10045 )))
10046 .execute(&admin_pool)
10047 .await
10048 .unwrap();
10049 let database = client.cache.database.as_ref().unwrap().clone();
10050 let payload_keys = client.payload_keys.clone();
10051 let restart_config = client.config.clone();
10052 drop(client);
10053 let (mut restarted, _) = swap_client_with_cache(restart_config);
10054 restarted.cache.database = Some(database);
10055 restarted.payload_keys = payload_keys;
10056 restarted.signer = Some(Arc::new(
10057 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
10058 ));
10059 let mut restart_receiver = start_with_events(&mut restarted);
10060
10061 restarted.reconcile_unresolved_execution().await.unwrap();
10062
10063 let events = collect_order_events(&mut restart_receiver);
10064 let (fill_emitted, active): (bool, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10065 "SELECT fill_emitted, active FROM {schema}.execution_intent"
10066 )))
10067 .fetch_one(&admin_pool)
10068 .await
10069 .unwrap();
10070 assert_eq!(events.len(), 1, "was: {events:?}");
10071 assert!(matches!(&events[0], OrderEventAny::Filled(_)));
10072 assert!(fill_emitted);
10073 assert!(!active);
10074
10075 drop_execution_schema(&admin_pool, &schema).await;
10076 }
10077
10078 #[tokio::test]
10079 async fn finalized_swap_ignores_unrelated_swap_logs() {
10080 let min_amount_out = expected_min_amount_out(50);
10081 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
10082 let receipt = finalized_swap_receipt_with_unrelated_swap(expected_hash);
10083 let state = finalized_swap_rpc_state(expected_hash, min_amount_out)
10084 .with_response("eth_getTransactionReceipt", &receipt);
10085 let Some((admin_pool, schema, mut client, _, _)) =
10086 swap_client_with_database("execution_submit_unrelated_log_test", state).await
10087 else {
10088 return;
10089 };
10090 let order = test_market_sell_order(test_pool().instrument_id);
10091 let mut receiver = start_with_events(&mut client);
10092
10093 client.submit_order(submit_order_cmd(&order)).unwrap();
10094 await_pending_tasks(&client).await;
10095
10096 let events = collect_order_events(&mut receiver);
10097 assert_swap_submitted_and_filled(&events);
10098
10099 drop_execution_schema(&admin_pool, &schema).await;
10100 }
10101
10102 #[tokio::test]
10103 async fn prepare_swap_buy_accepts_quote_spend_exact_boundary() {
10104 let amount_in = expected_buy_amount_in();
10105 let min_amount_out = expected_buy_min_amount_out(50);
10106 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10107 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10108 let max_amount = amount_in.to_string();
10109 let Some((admin_pool, schema, client, _, cache)) = swap_client_with_database_config(
10110 "execution_prepare_buy_test",
10111 state,
10112 move |http_rpc_url| {
10113 let mut config = buy_test_config(http_rpc_url);
10114 config.quote_spend_limits.as_mut().unwrap()[0].max_amount = max_amount;
10115 config
10116 },
10117 )
10118 .await
10119 else {
10120 return;
10121 };
10122 let order = test_market_buy_order(test_pool().instrument_id);
10123 cache
10124 .borrow_mut()
10125 .add_order(order.clone(), None, None, true)
10126 .unwrap();
10127
10128 let plan = client
10129 .prepare_swap(&submit_order_cmd(&order), &order)
10130 .unwrap();
10131
10132 assert_eq!(plan.token_in, USDC_ADDRESS);
10133 assert_eq!(plan.token_out, WETH_ADDRESS);
10134 assert_eq!(plan.amount_in, amount_in);
10135 assert_eq!(plan.min_amount_out, min_amount_out);
10136 assert_ne!(plan.token_in, WETH_ADDRESS);
10137
10138 drop_execution_schema(&admin_pool, &schema).await;
10139 }
10140
10141 #[tokio::test]
10142 async fn submit_order_broadcasts_buy_swap() {
10143 let amount_in = expected_buy_amount_in();
10144 let min_amount_out = expected_buy_min_amount_out(50);
10145 let (expected_hash, expected_raw) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10146 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10147 let Some((admin_pool, schema, mut client, state, cache)) =
10148 swap_client_with_buy_database("execution_submit_buy_success_test", state).await
10149 else {
10150 return;
10151 };
10152 let order = test_market_buy_order(test_pool().instrument_id);
10153 cache
10154 .borrow_mut()
10155 .add_order(order.clone(), None, None, true)
10156 .unwrap();
10157 let mut receiver = start_with_events(&mut client);
10158
10159 client.submit_order(submit_order_cmd(&order)).unwrap();
10160 await_pending_tasks(&client).await;
10161
10162 let events = collect_order_events(&mut receiver);
10163 assert_swap_submitted_and_filled(&events);
10164 let broadcasts: Vec<_> = state
10165 .recorded_requests()
10166 .into_iter()
10167 .filter(|request| request["method"] == "eth_sendRawTransaction")
10168 .collect();
10169 assert_eq!(broadcasts.len(), 1);
10170 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
10171
10172 drop_execution_schema(&admin_pool, &schema).await;
10173 }
10174
10175 #[tokio::test]
10176 async fn finalized_buy_swap_emits_fill_from_output_leg() {
10177 let amount_in = expected_buy_amount_in();
10178 let min_amount_out = expected_buy_min_amount_out(50);
10179 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10180 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10181 let Some((admin_pool, schema, mut client, state, cache)) =
10182 swap_client_with_buy_database("execution_submit_buy_fill_test", state).await
10183 else {
10184 return;
10185 };
10186 let order = test_market_buy_order(test_pool().instrument_id);
10187 cache
10188 .borrow_mut()
10189 .add_order(order.clone(), None, None, true)
10190 .unwrap();
10191 let mut receiver = start_with_events(&mut client);
10192 let plan = client
10193 .prepare_swap(&submit_order_cmd(&order), &order)
10194 .unwrap();
10195
10196 execute_swap(
10197 plan,
10198 client.transaction_executor().unwrap(),
10199 client.emitter.clone(),
10200 client.transaction_limits.max_quote_age_blocks,
10201 client.transaction_limits.deadline_seconds,
10202 )
10203 .await
10204 .unwrap();
10205
10206 let mut order_events = Vec::new();
10207
10208 while let Ok(event) = receiver.try_recv() {
10209 if let ExecutionEvent::Order(event) = event {
10210 order_events.push(event);
10211 }
10212 }
10213 assert_eq!(order_events.len(), 2, "was: {order_events:?}");
10214 assert!(matches!(&order_events[0], OrderEventAny::Submitted(_)));
10215 let OrderEventAny::Filled(fill) = &order_events[1] else {
10216 panic!("expected OrderFilled, was {:?}", order_events[1]);
10217 };
10218 let expected_commission = Money::from_u256(
10219 U256::from(50_112_u64) * U256::from(100_000_000_u64),
10220 test_pool().chain.native_currency(),
10221 )
10222 .unwrap();
10223 assert_eq!(fill.client_order_id, order.client_order_id());
10224 assert_eq!(fill.venue_order_id.as_str(), expected_hash.to_string());
10225 assert_eq!(fill.order_side, OrderSide::Buy);
10226 assert_eq!(fill.last_qty, Quantity::from("0.001"));
10227 assert_eq!(fill.currency.code, "USDC");
10228 assert_eq!(fill.commission, Some(expected_commission));
10229 assert_eq!(fill.liquidity_side, LiquiditySide::Taker);
10230 assert_eq!(
10231 Money::from_decimal(
10232 fill.last_qty.as_decimal() * fill.last_px.as_decimal(),
10233 fill.currency,
10234 )
10235 .unwrap(),
10236 Money::from_u256(amount_in, fill.currency).unwrap()
10237 );
10238
10239 let (fill_emitted, active): (bool, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
10240 "SELECT fill_emitted, active FROM {schema}.execution_intent"
10241 )))
10242 .fetch_one(&admin_pool)
10243 .await
10244 .unwrap();
10245 assert!(fill_emitted);
10246 assert!(!active);
10247
10248 let database = client.cache.database.as_ref().unwrap().clone();
10249 let payload_keys = client.payload_keys.clone();
10250 let restart_config = client.config.clone();
10251 drop(client);
10252 let (mut restarted, _) = swap_client_with_cache(restart_config);
10253 restarted.cache.database = Some(database);
10254 restarted.payload_keys = payload_keys;
10255 restarted.signer = Some(Arc::new(
10256 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
10257 ));
10258 let mut restart_receiver = start_with_events(&mut restarted);
10259 restarted.reconcile_unresolved_execution().await.unwrap();
10260 restarted.reconcile_unresolved_execution().await.unwrap();
10261 assert!(collect_order_events(&mut restart_receiver).is_empty());
10262 assert_eq!(
10263 state
10264 .recorded_requests()
10265 .iter()
10266 .filter(|request| request["method"] == "eth_sendRawTransaction")
10267 .count(),
10268 1
10269 );
10270
10271 drop_execution_schema(&admin_pool, &schema).await;
10272 }
10273
10274 #[tokio::test]
10275 async fn finalized_buy_swap_cancels_remainder_when_output_is_short() {
10276 let amount_in = expected_buy_amount_in();
10277 let min_amount_out = expected_buy_min_amount_out(50);
10278 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10279 let short_base_out = min_amount_out;
10280 let mut state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10281 state = state.with_response(
10282 "eth_getTransactionReceipt",
10283 &finalized_buy_swap_receipt_with_base_out(expected_hash, amount_in, short_base_out),
10284 );
10285 let Some((admin_pool, schema, mut client, _, cache)) =
10286 swap_client_with_buy_database("execution_submit_buy_short_fill_test", state).await
10287 else {
10288 return;
10289 };
10290 let order = test_market_buy_order(test_pool().instrument_id);
10291 cache
10292 .borrow_mut()
10293 .add_order(order.clone(), None, None, true)
10294 .unwrap();
10295 let mut receiver = start_with_events(&mut client);
10296 let plan = client
10297 .prepare_swap(&submit_order_cmd(&order), &order)
10298 .unwrap();
10299
10300 execute_swap(
10301 plan,
10302 client.transaction_executor().unwrap(),
10303 client.emitter.clone(),
10304 client.transaction_limits.max_quote_age_blocks,
10305 client.transaction_limits.deadline_seconds,
10306 )
10307 .await
10308 .unwrap();
10309
10310 let events = collect_order_events(&mut receiver);
10311 assert_eq!(events.len(), 3, "was: {events:?}");
10312 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
10313 let OrderEventAny::Filled(fill) = &events[1] else {
10314 panic!("expected OrderFilled, was {:?}", events[1]);
10315 };
10316 let expected_qty = raw_amount_to_quantity(short_base_out, 18).unwrap();
10317 assert_eq!(fill.order_side, OrderSide::Buy);
10318 assert_eq!(fill.last_qty, expected_qty);
10319 assert!(fill.last_qty < order.quantity());
10320 let OrderEventAny::Canceled(canceled) = &events[2] else {
10321 panic!("expected OrderCanceled, was {:?}", events[2]);
10322 };
10323 assert_eq!(canceled.client_order_id, order.client_order_id());
10324 assert_eq!(
10325 canceled.venue_order_id.as_ref().map(VenueOrderId::as_str),
10326 Some(expected_hash.to_string().as_str())
10327 );
10328
10329 drop_execution_schema(&admin_pool, &schema).await;
10330 }
10331
10332 #[tokio::test]
10333 async fn finalized_buy_swap_reports_full_output_when_above_order_quantity() {
10334 let amount_in = expected_buy_amount_in();
10335 let min_amount_out = expected_buy_min_amount_out(50);
10336 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10337 let overshoot_base_out = expected_buy_base_amount() * U256::from(2) + U256::from(44);
10338 let mut state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10339 state = state.with_response(
10340 "eth_getTransactionReceipt",
10341 &finalized_buy_swap_receipt_with_base_out(expected_hash, amount_in, overshoot_base_out),
10342 );
10343 let Some((admin_pool, schema, mut client, _, cache)) =
10344 swap_client_with_buy_database("execution_submit_buy_overshoot_fill_test", state).await
10345 else {
10346 return;
10347 };
10348 let order = test_market_buy_order(test_pool().instrument_id);
10349 cache
10350 .borrow_mut()
10351 .add_order(order.clone(), None, None, true)
10352 .unwrap();
10353 let mut receiver = start_with_events(&mut client);
10354 let plan = client
10355 .prepare_swap(&submit_order_cmd(&order), &order)
10356 .unwrap();
10357
10358 execute_swap(
10359 plan,
10360 client.transaction_executor().unwrap(),
10361 client.emitter.clone(),
10362 client.transaction_limits.max_quote_age_blocks,
10363 client.transaction_limits.deadline_seconds,
10364 )
10365 .await
10366 .unwrap();
10367
10368 let events = collect_order_events(&mut receiver);
10369 assert_eq!(events.len(), 2, "was: {events:?}");
10370 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
10371 let OrderEventAny::Filled(fill) = &events[1] else {
10372 panic!("expected OrderFilled, was {:?}", events[1]);
10373 };
10374 let expected_qty = raw_amount_to_quantity(overshoot_base_out, 18).unwrap();
10375 assert_eq!(fill.order_side, OrderSide::Buy);
10376 assert_eq!(fill.last_qty, expected_qty);
10377 assert!(fill.last_qty > order.quantity());
10378 assert_eq!(
10379 Money::from_decimal(
10380 fill.last_qty.as_decimal() * fill.last_px.as_decimal(),
10381 fill.currency,
10382 )
10383 .unwrap(),
10384 Money::from_u256(amount_in, fill.currency).unwrap()
10385 );
10386
10387 drop_execution_schema(&admin_pool, &schema).await;
10388 }
10389
10390 #[tokio::test]
10391 async fn finalized_buy_swap_quarantines_sell_oriented_log() {
10392 let amount_in = expected_buy_amount_in();
10393 let min_amount_out = expected_buy_min_amount_out(50);
10394 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10395 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in)
10396 .with_response(
10397 "eth_getTransactionReceipt",
10398 &finalized_swap_receipt(expected_hash),
10399 );
10400 let Some((admin_pool, schema, mut client, _, cache)) =
10401 swap_client_with_buy_database("execution_submit_buy_sell_log_test", state).await
10402 else {
10403 return;
10404 };
10405 let order = test_market_buy_order(test_pool().instrument_id);
10406 cache
10407 .borrow_mut()
10408 .add_order(order.clone(), None, None, true)
10409 .unwrap();
10410 let mut receiver = start_with_events(&mut client);
10411 let plan = client
10412 .prepare_swap(&submit_order_cmd(&order), &order)
10413 .unwrap();
10414
10415 let error = execute_swap(
10416 plan,
10417 client.transaction_executor().unwrap(),
10418 client.emitter.clone(),
10419 client.transaction_limits.max_quote_age_blocks,
10420 client.transaction_limits.deadline_seconds,
10421 )
10422 .await
10423 .unwrap_err();
10424
10425 let events = collect_order_events(&mut receiver);
10426 assert_swap_quarantined_without_terminal_event(&events);
10427 assert!(
10428 error
10429 .to_string()
10430 .contains("does not match the persisted amount")
10431 || error.to_string().contains("is not a BUY output"),
10432 "was: {error}"
10433 );
10434 assert!(client.in_flight.lock().is_some());
10435
10436 drop_execution_schema(&admin_pool, &schema).await;
10437 }
10438
10439 #[tokio::test]
10440 async fn submit_order_applies_buy_slippage_to_base_output() {
10441 let amount_in = expected_buy_amount_in();
10442 let min_amount_out = expected_buy_min_amount_out(200);
10443 let (expected_hash, expected_raw) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10444 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in);
10445 let Some((admin_pool, schema, mut client, state, cache)) =
10446 swap_client_with_buy_database("execution_submit_buy_slippage_test", state).await
10447 else {
10448 return;
10449 };
10450 let order = test_market_buy_order(test_pool().instrument_id);
10451 cache
10452 .borrow_mut()
10453 .add_order(order.clone(), None, None, true)
10454 .unwrap();
10455 let mut cmd = submit_order_cmd(&order);
10456 cmd.params = Some(serde_json::from_str(r#"{"slippage_bps": 200}"#).unwrap());
10457 let mut receiver = start_with_events(&mut client);
10458
10459 client.submit_order(cmd).unwrap();
10460 await_pending_tasks(&client).await;
10461
10462 let events = collect_order_events(&mut receiver);
10463 assert_swap_submitted_and_filled(&events);
10464 let broadcasts: Vec<_> = state
10465 .recorded_requests()
10466 .into_iter()
10467 .filter(|request| request["method"] == "eth_sendRawTransaction")
10468 .collect();
10469 assert_eq!(broadcasts.len(), 1);
10470 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
10471
10472 drop_execution_schema(&admin_pool, &schema).await;
10473 }
10474
10475 #[tokio::test]
10476 async fn submit_order_denies_buy_on_insufficient_quote_balance() {
10477 let amount_in = expected_buy_amount_in();
10478 let min_amount_out = expected_buy_min_amount_out(50);
10479 let (expected_hash, _) = expected_buy_swap_tx(min_amount_out, amount_in).await;
10480 let state = finalized_buy_swap_rpc_state(expected_hash, min_amount_out, amount_in)
10481 .with_call_response(BALANCE_OF_SELECTOR, CALL_ZERO);
10482 let Some((admin_pool, schema, mut client, _, cache)) =
10483 swap_client_with_buy_database("execution_submit_buy_balance_test", state).await
10484 else {
10485 return;
10486 };
10487 let order = test_market_buy_order(test_pool().instrument_id);
10488 cache
10489 .borrow_mut()
10490 .add_order(order.clone(), None, None, true)
10491 .unwrap();
10492 let mut receiver = start_with_events(&mut client);
10493
10494 client.submit_order(submit_order_cmd(&order)).unwrap();
10495 await_pending_tasks(&client).await;
10496
10497 let events = collect_order_events(&mut receiver);
10498 assert_eq!(events.len(), 1);
10499 let OrderEventAny::Denied(denied) = &events[0] else {
10500 panic!("expected OrderDenied, was {:?}", events[0]);
10501 };
10502 assert!(
10503 denied.reason.contains("is below the swap amount"),
10504 "was: {}",
10505 denied.reason
10506 );
10507
10508 drop_execution_schema(&admin_pool, &schema).await;
10509 }
10510
10511 #[tokio::test]
10512 async fn finalized_swap_without_log_stays_quarantined() {
10513 let state = swap_rpc_state()
10514 .await
10515 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS);
10516 let Some((admin_pool, schema, mut client, _, _)) =
10517 swap_client_with_database("execution_submit_missing_log_test", state).await
10518 else {
10519 return;
10520 };
10521 let order = test_market_sell_order(test_pool().instrument_id);
10522 let mut receiver = start_with_events(&mut client);
10523
10524 client.submit_order(submit_order_cmd(&order)).unwrap();
10525 await_pending_tasks(&client).await;
10526
10527 let events = collect_order_events(&mut receiver);
10528 let (status, terminal_emitted, active): (String, bool, bool) =
10529 sqlx::query_as(sqlx::AssertSqlSafe(format!(
10530 "SELECT status, terminal_emitted, active FROM {schema}.execution_intent"
10531 )))
10532 .fetch_one(&admin_pool)
10533 .await
10534 .unwrap();
10535
10536 assert_swap_quarantined_without_terminal_event(&events);
10537 assert_eq!(status, "broadcast");
10538 assert!(!terminal_emitted);
10539 assert!(active);
10540 assert!(client.in_flight.lock().is_some());
10541
10542 drop_execution_schema(&admin_pool, &schema).await;
10543 }
10544
10545 #[tokio::test]
10546 async fn finalized_swap_refresh_failure_stays_owned_for_reconciliation() {
10547 let min_amount_out = expected_min_amount_out(50);
10548 let (expected_hash, _) = expected_swap_tx(min_amount_out).await;
10549 let state = finalized_swap_rpc_state(expected_hash, min_amount_out).with_response_sequence(
10550 "eth_getBalance",
10551 &[
10552 GET_BALANCE,
10553 GET_BALANCE,
10554 GET_BALANCE,
10555 RPC_METHOD_NOT_FOUND,
10556 RPC_METHOD_NOT_FOUND,
10557 RPC_METHOD_NOT_FOUND,
10558 ],
10559 );
10560 let Some((admin_pool, schema, mut client, _state, _)) =
10561 swap_client_with_database("execution_submit_refresh_fail_test", state).await
10562 else {
10563 return;
10564 };
10565 let order = test_market_sell_order(test_pool().instrument_id);
10566 let mut receiver = start_with_events(&mut client);
10567 let plan = client
10568 .prepare_swap(&submit_order_cmd(&order), &order)
10569 .unwrap();
10570
10571 let error = execute_swap(
10572 plan,
10573 client.transaction_executor().unwrap(),
10574 client.emitter.clone(),
10575 client.transaction_limits.max_quote_age_blocks,
10576 client.transaction_limits.deadline_seconds,
10577 )
10578 .await
10579 .unwrap_err();
10580 let events = collect_order_events(&mut receiver);
10581 let (status, fill_emitted, active): (String, bool, bool) =
10582 sqlx::query_as(sqlx::AssertSqlSafe(format!(
10583 "SELECT status, fill_emitted, active FROM {schema}.execution_intent"
10584 )))
10585 .fetch_one(&admin_pool)
10586 .await
10587 .unwrap();
10588
10589 assert!(
10590 error
10591 .to_string()
10592 .contains("finalized native balance verification is locally invalid"),
10593 "was: {error}"
10594 );
10595 assert_eq!(events.len(), 1, "was: {events:?}");
10596 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
10597 assert_eq!(status, "broadcast");
10598 assert!(!fill_emitted);
10599 assert!(active);
10600 assert!(client.in_flight.lock().is_some());
10601
10602 drop_execution_schema(&admin_pool, &schema).await;
10603 }
10604
10605 #[tokio::test]
10606 async fn submit_order_denies_router_factory_mismatch_before_signing() {
10607 let state = swap_rpc_state()
10608 .await
10609 .with_call_response(FACTORY_SELECTOR, CALL_ZERO);
10610 let Some((admin_pool, schema, mut client, state, _)) =
10611 swap_client_with_database("execution_submit_factory_test", state).await
10612 else {
10613 return;
10614 };
10615 let order = test_market_sell_order(test_pool().instrument_id);
10616 let mut receiver = start_with_events(&mut client);
10617
10618 client.submit_order(submit_order_cmd(&order)).unwrap();
10619 await_pending_tasks(&client).await;
10620
10621 let events = collect_order_events(&mut receiver);
10622 assert_eq!(events.len(), 1);
10623 let OrderEventAny::Denied(denied) = &events[0] else {
10624 panic!("expected OrderDenied, was {:?}", events[0]);
10625 };
10626 assert!(
10627 denied
10628 .reason
10629 .contains("swap deployment manifest verification disagreed"),
10630 "was: {}",
10631 denied.reason
10632 );
10633 let requests = state.recorded_requests();
10634 assert!(
10635 requests
10636 .iter()
10637 .all(|request| request["method"] != "eth_getTransactionCount")
10638 );
10639 assert!(
10640 requests
10641 .iter()
10642 .all(|request| request["method"] != "eth_sendRawTransaction")
10643 );
10644
10645 drop_execution_schema(&admin_pool, &schema).await;
10646 }
10647
10648 #[tokio::test]
10649 async fn submit_order_denies_router_weth_mismatch_before_signing() {
10650 let state = swap_rpc_state()
10651 .await
10652 .with_call_response(WETH9_SELECTOR, CALL_ZERO);
10653 let Some((admin_pool, schema, mut client, state, _)) =
10654 swap_client_with_database("execution_submit_weth_test", state).await
10655 else {
10656 return;
10657 };
10658 let order = test_market_sell_order(test_pool().instrument_id);
10659 let mut receiver = start_with_events(&mut client);
10660
10661 client.submit_order(submit_order_cmd(&order)).unwrap();
10662 await_pending_tasks(&client).await;
10663
10664 let events = collect_order_events(&mut receiver);
10665 assert_eq!(events.len(), 1);
10666 let OrderEventAny::Denied(denied) = &events[0] else {
10667 panic!("expected OrderDenied, was {:?}", events[0]);
10668 };
10669 assert!(
10670 denied
10671 .reason
10672 .contains("swap deployment manifest verification disagreed"),
10673 "was: {}",
10674 denied.reason
10675 );
10676 let requests = state.recorded_requests();
10677 assert!(
10678 requests
10679 .iter()
10680 .all(|request| request["method"] != "eth_getTransactionCount")
10681 );
10682 assert!(
10683 requests
10684 .iter()
10685 .all(|request| request["method"] != "eth_sendRawTransaction")
10686 );
10687
10688 drop_execution_schema(&admin_pool, &schema).await;
10689 }
10690
10691 #[tokio::test]
10692 async fn submit_order_denies_factory_pool_mismatch_before_signing() {
10693 let state = swap_rpc_state()
10694 .await
10695 .with_call_response(GET_POOL_SELECTOR, CALL_ZERO);
10696 let Some((admin_pool, schema, mut client, state, _)) =
10697 swap_client_with_database("execution_submit_pool_identity_test", state).await
10698 else {
10699 return;
10700 };
10701 let order = test_market_sell_order(test_pool().instrument_id);
10702 let mut receiver = start_with_events(&mut client);
10703
10704 client.submit_order(submit_order_cmd(&order)).unwrap();
10705 await_pending_tasks(&client).await;
10706
10707 let events = collect_order_events(&mut receiver);
10708 assert_eq!(events.len(), 1);
10709 let OrderEventAny::Denied(denied) = &events[0] else {
10710 panic!("expected OrderDenied, was {:?}", events[0]);
10711 };
10712 assert!(
10713 denied
10714 .reason
10715 .contains("swap deployment manifest verification disagreed"),
10716 "was: {}",
10717 denied.reason
10718 );
10719 let requests = state.recorded_requests();
10720 assert!(
10721 requests
10722 .iter()
10723 .all(|request| request["method"] != "eth_getTransactionCount")
10724 );
10725 assert!(
10726 requests
10727 .iter()
10728 .all(|request| request["method"] != "eth_sendRawTransaction")
10729 );
10730
10731 drop_execution_schema(&admin_pool, &schema).await;
10732 }
10733
10734 #[tokio::test]
10735 async fn submit_order_denies_cached_token_decimal_mismatch_before_signing() {
10736 let state = swap_rpc_state().await.with_contract_call_response(
10737 WETH,
10738 DECIMALS_SELECTOR,
10739 CALL_DECIMALS_6,
10740 );
10741 let Some((admin_pool, schema, mut client, state, _)) =
10742 swap_client_with_database("execution_submit_decimals_test", state).await
10743 else {
10744 return;
10745 };
10746 let order = test_market_sell_order(test_pool().instrument_id);
10747 let mut receiver = start_with_events(&mut client);
10748
10749 client.submit_order(submit_order_cmd(&order)).unwrap();
10750 await_pending_tasks(&client).await;
10751
10752 let events = collect_order_events(&mut receiver);
10753 assert_eq!(events.len(), 1);
10754 let OrderEventAny::Denied(denied) = &events[0] else {
10755 panic!("expected OrderDenied, was {:?}", events[0]);
10756 };
10757 assert!(
10758 denied
10759 .reason
10760 .contains("swap deployment manifest verification disagreed"),
10761 "was: {}",
10762 denied.reason
10763 );
10764 let requests = state.recorded_requests();
10765 assert!(
10766 requests
10767 .iter()
10768 .all(|request| request["method"] != "eth_getTransactionCount")
10769 );
10770 assert!(
10771 requests
10772 .iter()
10773 .all(|request| request["method"] != "eth_sendRawTransaction")
10774 );
10775
10776 drop_execution_schema(&admin_pool, &schema).await;
10777 }
10778
10779 #[tokio::test]
10780 async fn submit_order_denies_on_insufficient_router_allowance() {
10781 let state = swap_rpc_state()
10782 .await
10783 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO);
10784 let Some((admin_pool, schema, mut client, state, _)) =
10785 swap_client_with_database("execution_submit_allowance_test", state).await
10786 else {
10787 return;
10788 };
10789 let order = test_market_sell_order(test_pool().instrument_id);
10790 let mut receiver = start_with_events(&mut client);
10791
10792 client.submit_order(submit_order_cmd(&order)).unwrap();
10793 await_pending_tasks(&client).await;
10794
10795 let events = collect_order_events(&mut receiver);
10796 assert_eq!(events.len(), 1);
10797 let OrderEventAny::Denied(denied) = &events[0] else {
10798 panic!("expected OrderDenied, was {:?}", events[0]);
10799 };
10800 assert!(
10801 denied.reason.contains("below the swap amount"),
10802 "was: {}",
10803 denied.reason
10804 );
10805 let requests = state.recorded_requests();
10806 assert!(
10807 requests
10808 .iter()
10809 .all(|request| request["method"] != "eth_sendRawTransaction"),
10810 "no broadcast may follow a pre-trade denial"
10811 );
10812 let row_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
10813 "SELECT COUNT(*) FROM {schema}.execution_intent"
10814 )))
10815 .fetch_one(&admin_pool)
10816 .await
10817 .unwrap();
10818 assert_eq!(row_count, 0);
10819 assert!(client.in_flight.lock().is_none());
10820
10821 drop_execution_schema(&admin_pool, &schema).await;
10822 }
10823
10824 #[tokio::test]
10825 async fn submit_order_denies_on_insufficient_input_balance() {
10826 let state = swap_rpc_state()
10827 .await
10828 .with_call_response(BALANCE_OF_SELECTOR, CALL_ZERO);
10829 let Some((admin_pool, schema, mut client, _, _)) =
10830 swap_client_with_database("execution_submit_balance_test", state).await
10831 else {
10832 return;
10833 };
10834 let order = test_market_sell_order(test_pool().instrument_id);
10835 let mut receiver = start_with_events(&mut client);
10836
10837 client.submit_order(submit_order_cmd(&order)).unwrap();
10838 await_pending_tasks(&client).await;
10839
10840 let events = collect_order_events(&mut receiver);
10841 assert_eq!(events.len(), 1);
10842 let OrderEventAny::Denied(denied) = &events[0] else {
10843 panic!("expected OrderDenied, was {:?}", events[0]);
10844 };
10845 assert!(
10846 denied.reason.contains("is below the swap amount"),
10847 "was: {}",
10848 denied.reason
10849 );
10850
10851 drop_execution_schema(&admin_pool, &schema).await;
10852 }
10853
10854 #[tokio::test]
10855 async fn submit_order_denies_on_insufficient_native_balance() {
10856 let state = swap_rpc_state()
10857 .await
10858 .with_response("eth_getBalance", GET_BALANCE_INSUFFICIENT);
10859 let Some((admin_pool, schema, mut client, state, _)) =
10860 swap_client_with_database("execution_submit_native_balance_test", state).await
10861 else {
10862 return;
10863 };
10864 let order = test_market_sell_order(test_pool().instrument_id);
10865 let mut receiver = start_with_events(&mut client);
10866
10867 client.submit_order(submit_order_cmd(&order)).unwrap();
10868 await_pending_tasks(&client).await;
10869
10870 let events = collect_order_events(&mut receiver);
10871 assert_eq!(events.len(), 1);
10872 let OrderEventAny::Denied(denied) = &events[0] else {
10873 panic!("expected OrderDenied, was {:?}", events[0]);
10874 };
10875 assert!(
10876 denied.reason.contains("below maximum transaction cost"),
10877 "was: {}",
10878 denied.reason
10879 );
10880 assert!(
10881 state
10882 .recorded_requests()
10883 .iter()
10884 .all(|request| request["method"] != "eth_sendRawTransaction"),
10885 "no broadcast may follow an insufficient native balance"
10886 );
10887 let (row_count, status): (i64, Option<String>) = sqlx::query_as(sqlx::AssertSqlSafe(
10888 format!("SELECT COUNT(*), MAX(status) FROM {schema}.execution_intent"),
10889 ))
10890 .fetch_one(&admin_pool)
10891 .await
10892 .unwrap();
10893 assert_eq!(row_count, 1);
10894 assert_eq!(status.as_deref(), Some("recoverable"));
10895 assert!(client.in_flight.lock().is_none());
10896
10897 drop_execution_schema(&admin_pool, &schema).await;
10898 }
10899
10900 #[tokio::test]
10901 async fn submit_order_denies_on_stale_quote() {
10902 let Some((admin_pool, schema, mut client, state, cache)) =
10903 swap_client_with_database("execution_submit_stale_quote_test", swap_rpc_state().await)
10904 .await
10905 else {
10906 return;
10907 };
10908 let pool = test_pool();
10909 cache
10910 .borrow_mut()
10911 .add_pool_profiler(test_profiler(&pool, FIXTURE_BLOCK - 101))
10912 .unwrap();
10913 let order = test_market_sell_order(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.reason.contains("Stale quote"),
10926 "was: {}",
10927 denied.reason
10928 );
10929 let requests = state.recorded_requests();
10930 assert!(
10931 requests
10932 .iter()
10933 .all(|request| request["method"] != "eth_sendRawTransaction"),
10934 "no broadcast may follow a pre-trade denial"
10935 );
10936
10937 drop_execution_schema(&admin_pool, &schema).await;
10938 }
10939
10940 #[tokio::test]
10941 async fn submit_order_denies_quote_ahead_of_chain_head() {
10942 let Some((admin_pool, schema, mut client, state, cache)) =
10943 swap_client_with_database("execution_submit_ahead_quote_test", swap_rpc_state().await)
10944 .await
10945 else {
10946 return;
10947 };
10948 let pool = test_pool();
10949 cache
10950 .borrow_mut()
10951 .add_pool_profiler(test_profiler(&pool, FIXTURE_BLOCK + 1))
10952 .unwrap();
10953 let order = test_market_sell_order(pool.instrument_id);
10954 let mut receiver = start_with_events(&mut client);
10955
10956 client.submit_order(submit_order_cmd(&order)).unwrap();
10957 await_pending_tasks(&client).await;
10958
10959 let events = collect_order_events(&mut receiver);
10960 assert_eq!(events.len(), 1);
10961 let OrderEventAny::Denied(denied) = &events[0] else {
10962 panic!("expected OrderDenied, was {:?}", events[0]);
10963 };
10964 assert!(
10965 denied.reason.contains("is ahead of the latest block"),
10966 "was: {}",
10967 denied.reason
10968 );
10969 let requests = state.recorded_requests();
10970 assert!(
10971 requests
10972 .iter()
10973 .all(|request| request["method"] != "eth_sendRawTransaction"),
10974 "no broadcast may follow a pre-trade denial"
10975 );
10976
10977 drop_execution_schema(&admin_pool, &schema).await;
10978 }
10979
10980 #[tokio::test]
10981 async fn submit_order_denies_on_deadline_overflow() {
10982 let Some((admin_pool, schema, mut client, state, _)) = swap_client_with_database(
10983 "execution_submit_deadline_overflow_test",
10984 swap_rpc_state().await,
10985 )
10986 .await
10987 else {
10988 return;
10989 };
10990 client.transaction_limits.deadline_seconds = u64::MAX;
10991 let order = test_market_sell_order(test_pool().instrument_id);
10992 let mut receiver = start_with_events(&mut client);
10993
10994 client.submit_order(submit_order_cmd(&order)).unwrap();
10995 await_pending_tasks(&client).await;
10996
10997 let events = collect_order_events(&mut receiver);
10998 assert_eq!(events.len(), 1);
10999 let OrderEventAny::Denied(denied) = &events[0] else {
11000 panic!("expected OrderDenied, was {:?}", events[0]);
11001 };
11002 assert!(
11003 denied.reason.contains("deadline overflow"),
11004 "was: {}",
11005 denied.reason
11006 );
11007 let requests = state.recorded_requests();
11008 assert!(
11009 requests
11010 .iter()
11011 .all(|request| request["method"] != "eth_sendRawTransaction"),
11012 "no broadcast may follow a pre-trade denial"
11013 );
11014 assert!(client.in_flight.lock().is_none());
11015
11016 drop_execution_schema(&admin_pool, &schema).await;
11017 }
11018
11019 #[tokio::test]
11020 async fn submit_order_reconciles_node_rejection_to_finalized_receipt() {
11021 let state = swap_rpc_state()
11022 .await
11023 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_REJECTED);
11024 let Some((admin_pool, schema, mut client, _state, _)) =
11025 swap_client_with_database("execution_submit_node_rejected_test", state).await
11026 else {
11027 return;
11028 };
11029 let order = test_market_sell_order(test_pool().instrument_id);
11030 let mut receiver = start_with_events(&mut client);
11031
11032 client.submit_order(submit_order_cmd(&order)).unwrap();
11033 await_pending_tasks(&client).await;
11034
11035 let events = collect_order_events(&mut receiver);
11036 assert_swap_submitted_and_filled(&events);
11037 let (purpose, status, client_order_id): (String, String, Option<String>) =
11038 sqlx::query_as(sqlx::AssertSqlSafe(format!(
11039 "SELECT purpose, status, client_order_id FROM {schema}.execution_intent"
11040 )))
11041 .fetch_one(&admin_pool)
11042 .await
11043 .unwrap();
11044 assert_eq!(purpose, "swap");
11045 assert_eq!(status, "finalized");
11046 assert_eq!(
11047 client_order_id.as_deref(),
11048 Some(order.client_order_id().as_str())
11049 );
11050 assert!(client.in_flight.lock().is_none());
11051
11052 drop_execution_schema(&admin_pool, &schema).await;
11053 }
11054
11055 #[tokio::test]
11056 async fn submit_order_acknowledges_uncertain_nonce_too_low() {
11057 let state = swap_rpc_state()
11058 .await
11059 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_NONCE_TOO_LOW);
11060 let Some((admin_pool, schema, mut client, _state, _)) =
11061 swap_client_with_database("execution_submit_nonce_too_low_test", state).await
11062 else {
11063 return;
11064 };
11065 let order = test_market_sell_order(test_pool().instrument_id);
11066 let mut receiver = start_with_events(&mut client);
11067
11068 client.submit_order(submit_order_cmd(&order)).unwrap();
11069 await_pending_tasks(&client).await;
11070
11071 let events = collect_order_events(&mut receiver);
11072 assert_swap_submitted_and_filled(&events);
11073 let (purpose, status, client_order_id): (String, String, Option<String>) =
11074 sqlx::query_as(sqlx::AssertSqlSafe(format!(
11075 "SELECT purpose, status, client_order_id FROM {schema}.execution_intent"
11076 )))
11077 .fetch_one(&admin_pool)
11078 .await
11079 .unwrap();
11080 assert_eq!(purpose, "swap");
11081 assert_eq!(status, "finalized");
11082 assert_eq!(
11083 client_order_id.as_deref(),
11084 Some(order.client_order_id().as_str())
11085 );
11086 assert!(client.in_flight.lock().is_none());
11087
11088 drop_execution_schema(&admin_pool, &schema).await;
11089 }
11090
11091 #[tokio::test]
11092 async fn submit_order_rejected_on_reverted_receipt() {
11093 let expected_min_out = expected_min_amount_out(50);
11094 let (expected_hash, _) = expected_swap_tx(expected_min_out).await;
11095 let block = finalized_swap_block(expected_hash, expected_min_out);
11096 let receipt = receipt_with_transaction_hash(RECEIPT_REVERTED, expected_hash);
11097 let state = with_finalized_identity(
11098 swap_rpc_state()
11099 .await
11100 .with_response("eth_getTransactionReceipt", &receipt),
11101 &block,
11102 &receipt,
11103 );
11104 let Some((admin_pool, schema, mut client, _state, _)) =
11105 swap_client_with_database("execution_submit_reverted_test", state).await
11106 else {
11107 return;
11108 };
11109 let order = test_market_sell_order(test_pool().instrument_id);
11110 let mut receiver = start_with_events(&mut client);
11111 client.submit_order(submit_order_cmd(&order)).unwrap();
11112 await_pending_tasks(&client).await;
11113
11114 let events = collect_order_events(&mut receiver);
11115 assert_eq!(events.len(), 2);
11116 assert!(
11117 matches!(&events[0], OrderEventAny::Submitted(_)),
11118 "was: {:?}",
11119 events[0]
11120 );
11121 let OrderEventAny::Rejected(rejected) = &events[1] else {
11122 panic!("expected OrderRejected, was {:?}", events[1]);
11123 };
11124 assert!(
11125 rejected.reason.contains("reverted on-chain"),
11126 "was: {}",
11127 rejected.reason
11128 );
11129
11130 let record = client
11131 .cache
11132 .get_execution_transaction(42161, &expected_hash.to_string())
11133 .await
11134 .unwrap()
11135 .unwrap();
11136 assert_eq!(record.purpose, "swap");
11137 assert_eq!(record.status, "reverted");
11138 assert_eq!(
11139 record.client_order_id.as_deref(),
11140 Some(order.client_order_id().as_str())
11141 );
11142 assert!(client.in_flight.lock().is_none());
11143
11144 drop_execution_schema(&admin_pool, &schema).await;
11145 }
11146
11147 #[tokio::test]
11148 async fn submit_order_acknowledges_ambiguous_broadcast() {
11149 let state = swap_rpc_state()
11152 .await
11153 .with_response("eth_sendRawTransaction", "not json");
11154 let Some((admin_pool, schema, mut client, _state, _)) =
11155 swap_client_with_database("execution_submit_ambiguous_test", state).await
11156 else {
11157 return;
11158 };
11159 let order = test_market_sell_order(test_pool().instrument_id);
11160 let mut receiver = start_with_events(&mut client);
11161
11162 client.submit_order(submit_order_cmd(&order)).unwrap();
11163 await_pending_tasks(&client).await;
11164
11165 let events = collect_order_events(&mut receiver);
11166 assert_swap_submitted_and_filled(&events);
11167
11168 let record = client
11169 .cache
11170 .get_execution_transaction(
11171 42161,
11172 &expected_swap_tx(expected_min_amount_out(50))
11173 .await
11174 .0
11175 .to_string(),
11176 )
11177 .await
11178 .unwrap()
11179 .unwrap();
11180 assert_eq!(record.purpose, "swap");
11181 assert_eq!(record.status, "finalized");
11182 assert!(client.in_flight.lock().is_none());
11183
11184 drop_execution_schema(&admin_pool, &schema).await;
11185 }
11186
11187 #[tokio::test]
11188 async fn submit_order_acknowledges_broadcast_hash_mismatch() {
11189 let state = swap_rpc_state_for_mismatch().await;
11192 let Some((admin_pool, schema, mut client, _state, _)) =
11193 swap_client_with_database("execution_submit_hash_mismatch_test", state).await
11194 else {
11195 return;
11196 };
11197 let order = test_market_sell_order(test_pool().instrument_id);
11198 let mut receiver = start_with_events(&mut client);
11199
11200 client.submit_order(submit_order_cmd(&order)).unwrap();
11201 await_pending_tasks(&client).await;
11202
11203 let events = collect_order_events(&mut receiver);
11204 assert_swap_submitted_and_filled(&events);
11205
11206 let record = client
11207 .cache
11208 .get_execution_transaction(
11209 42161,
11210 &expected_swap_tx(expected_min_amount_out(50))
11211 .await
11212 .0
11213 .to_string(),
11214 )
11215 .await
11216 .unwrap()
11217 .unwrap();
11218 assert_eq!(record.purpose, "swap");
11219 assert_eq!(record.status, "finalized");
11220 assert!(client.in_flight.lock().is_none());
11221
11222 drop_execution_schema(&admin_pool, &schema).await;
11223 }
11224
11225 #[tokio::test]
11226 async fn submit_order_persists_authenticated_envelope_before_broadcast() {
11227 let state = swap_rpc_state().await;
11228 let Some((admin_pool, schema, mut client, state, _)) =
11229 swap_client_with_database("protected_submit_test", state).await
11230 else {
11231 return;
11232 };
11233 let order = test_market_sell_order(test_pool().instrument_id);
11234 let mut receiver = start_with_events(&mut client);
11235
11236 client.submit_order(submit_order_cmd(&order)).unwrap();
11237 await_pending_tasks(&client).await;
11238
11239 let events = collect_order_events(&mut receiver);
11240 assert_swap_submitted_and_filled(&events);
11241 let representations: Vec<(bool, bool)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
11242 "SELECT raw_transaction IS NULL, sealed_transaction IS NOT NULL \
11243 FROM {schema}.execution_transaction_hash WHERE payload_expected"
11244 )))
11245 .fetch_all(&admin_pool)
11246 .await
11247 .unwrap();
11248 let broadcasts = state
11249 .recorded_requests()
11250 .into_iter()
11251 .filter(|request| request["method"] == "eth_sendRawTransaction")
11252 .count();
11253 assert_eq!(representations, vec![(true, true)]);
11254 assert_eq!(broadcasts, 1);
11255
11256 drop_execution_schema(&admin_pool, &schema).await;
11257 }
11258
11259 #[tokio::test]
11260 async fn protected_persistence_failure_prevents_broadcast_and_acknowledgment() {
11261 let state = swap_rpc_state().await;
11262 let Some((admin_pool, schema, mut client, state, _)) =
11263 swap_client_with_database("protected_persist_failure_test", state).await
11264 else {
11265 return;
11266 };
11267
11268 for statement in [
11269 format!(
11270 "CREATE FUNCTION {schema}.reject_protected_payload() RETURNS trigger \
11271 LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''test payload rejection''; END'"
11272 ),
11273 format!(
11274 "CREATE TRIGGER reject_protected_payload BEFORE INSERT ON \
11275 {schema}.execution_transaction_hash FOR EACH ROW \
11276 EXECUTE FUNCTION {schema}.reject_protected_payload()"
11277 ),
11278 ] {
11279 sqlx::query(sqlx::AssertSqlSafe(statement))
11280 .execute(&admin_pool)
11281 .await
11282 .unwrap();
11283 }
11284 let order = test_market_sell_order(test_pool().instrument_id);
11285 let mut receiver = start_with_events(&mut client);
11286
11287 client.submit_order(submit_order_cmd(&order)).unwrap();
11288 await_pending_tasks(&client).await;
11289
11290 let events = collect_order_events(&mut receiver);
11291 assert!(
11292 events
11293 .iter()
11294 .all(|event| !matches!(event, OrderEventAny::Submitted(_)))
11295 );
11296 let broadcasts = state
11297 .recorded_requests()
11298 .into_iter()
11299 .filter(|request| request["method"] == "eth_sendRawTransaction")
11300 .count();
11301 let payload_rows: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11302 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
11303 )))
11304 .fetch_one(&admin_pool)
11305 .await
11306 .unwrap();
11307 assert_eq!(broadcasts, 0);
11308 assert_eq!(payload_rows, 0);
11309
11310 drop_execution_schema(&admin_pool, &schema).await;
11311 }
11312
11313 #[tokio::test]
11314 async fn submit_order_reservation_failure_denies_without_broadcast_and_releases_slot() {
11315 let Some((admin_pool, schema, mut client, state, cache)) =
11316 swap_client_with_database("execution_submit_persist_fail_test", swap_rpc_state().await)
11317 .await
11318 else {
11319 return;
11320 };
11321 sqlx::query(sqlx::AssertSqlSafe(format!(
11322 "DROP TABLE {schema}.execution_intent CASCADE"
11323 )))
11324 .execute(&admin_pool)
11325 .await
11326 .unwrap();
11327 let pool = test_pool();
11328 let first = test_market_sell_order(pool.instrument_id);
11329 let second = market_sell_order_with_id(pool.instrument_id, "O-SWAP-002");
11330 cache
11331 .borrow_mut()
11332 .add_order(second.clone(), None, None, false)
11333 .unwrap();
11334 let mut receiver = start_with_events(&mut client);
11335
11336 client.submit_order(submit_order_cmd(&first)).unwrap();
11337 await_pending_tasks(&client).await;
11338
11339 let events = collect_order_events(&mut receiver);
11340 assert_eq!(events.len(), 1);
11341 let OrderEventAny::Denied(denied) = &events[0] else {
11342 panic!("expected OrderDenied, was {:?}", events[0]);
11343 };
11344 assert!(
11345 denied
11346 .reason
11347 .contains("Execution intent reservation failed before commit"),
11348 "was: {}",
11349 denied.reason
11350 );
11351 client.submit_order(submit_order_cmd(&second)).unwrap();
11352 await_pending_tasks(&client).await;
11353 let retry_events = collect_order_events(&mut receiver);
11354 assert_eq!(retry_events.len(), 1);
11355 let OrderEventAny::Denied(retry_denied) = &retry_events[0] else {
11356 panic!("expected OrderDenied, was {:?}", retry_events[0]);
11357 };
11358 assert_eq!(
11359 retry_denied.reason,
11360 "Execution intent reservation failed before commit"
11361 );
11362 let broadcasts = state
11363 .recorded_requests()
11364 .into_iter()
11365 .filter(|request| request["method"] == "eth_sendRawTransaction")
11366 .count();
11367 assert_eq!(broadcasts, 0);
11368 assert!(client.in_flight.lock().is_none());
11369
11370 drop_execution_schema(&admin_pool, &schema).await;
11371 }
11372
11373 #[tokio::test]
11374 async fn submit_order_reservation_commit_failure_keeps_preparing_slot() {
11375 let Some((admin_pool, schema, mut client, state, cache)) = swap_client_with_database(
11376 "execution_submit_reservation_commit_fail_test",
11377 swap_rpc_state().await,
11378 )
11379 .await
11380 else {
11381 return;
11382 };
11383 install_reservation_commit_rejection(&admin_pool, &schema).await;
11384 let pool = test_pool();
11385 let first = test_market_sell_order(pool.instrument_id);
11386 let second = market_sell_order_with_id(pool.instrument_id, "O-SWAP-002");
11387 cache
11388 .borrow_mut()
11389 .add_order(second.clone(), None, None, false)
11390 .unwrap();
11391 let mut receiver = start_with_events(&mut client);
11392
11393 client.submit_order(submit_order_cmd(&first)).unwrap();
11394 await_pending_tasks(&client).await;
11395 client.submit_order(submit_order_cmd(&second)).unwrap();
11396 await_pending_tasks(&client).await;
11397
11398 let events = collect_order_events(&mut receiver);
11399 let submitted = events
11400 .iter()
11401 .filter(|event| matches!(event, OrderEventAny::Submitted(_)))
11402 .count();
11403 let denied_commit = events
11404 .iter()
11405 .filter(|event| {
11406 matches!(event, OrderEventAny::Denied(denied) if denied.reason == "Execution intent reservation commit outcome is unknown; reconciliation is required")
11407 })
11408 .count();
11409 let denied_in_flight = events
11410 .iter()
11411 .filter(|event| {
11412 matches!(event, OrderEventAny::Denied(denied) if denied.reason.contains("at most one transaction can be in flight"))
11413 })
11414 .count();
11415 let requests = state.recorded_requests();
11416 let intent_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11417 "SELECT COUNT(*) FROM {schema}.execution_intent"
11418 )))
11419 .fetch_one(&admin_pool)
11420 .await
11421 .unwrap();
11422 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11423 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
11424 )))
11425 .fetch_one(&admin_pool)
11426 .await
11427 .unwrap();
11428
11429 assert_eq!(submitted, 0, "was: {events:?}");
11430 assert_eq!(denied_commit, 1, "was: {events:?}");
11431 assert_eq!(denied_in_flight, 1, "was: {events:?}");
11432 assert!(matches!(
11433 *client.in_flight.lock(),
11434 Some(InFlightSlot::Preparing(TransactionPurpose::Swap))
11435 ));
11436 assert_eq!(intent_count, 0);
11437 assert_eq!(signed_count, 0);
11438 assert!(
11439 requests
11440 .iter()
11441 .all(|request| request["method"] != "eth_getTransactionCount")
11442 );
11443 assert!(
11444 requests
11445 .iter()
11446 .all(|request| request["method"] != "eth_sendRawTransaction")
11447 );
11448
11449 drop_execution_schema(&admin_pool, &schema).await;
11450 }
11451
11452 #[tokio::test]
11453 async fn protected_payload_migration_failure_keeps_plaintext_and_blocks_ready() {
11454 let Some((admin_pool, schema, client, _)) = execution_client_with_unprotected_database(
11455 "protected_payload_migration_failure",
11456 execution_rpc_state(),
11457 )
11458 .await
11459 else {
11460 return;
11461 };
11462 let database = client.cache.database.as_ref().unwrap();
11463 let (intent, _) = persist_invalid_test_swap(database, None).await;
11464 let keys = payload_test_keys([0x31; 32], vec![], "migration-failure");
11465 let error = database
11466 .ensure_execution_payload_storage(&keys)
11467 .await
11468 .unwrap_err();
11469
11470 assert!(
11471 error
11472 .to_string()
11473 .contains("failed to authenticate execution payload")
11474 );
11475 let row = database
11476 .get_execution_transaction_hashes(intent.id)
11477 .await
11478 .unwrap()
11479 .pop()
11480 .unwrap();
11481 let operation: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11482 "SELECT operation FROM {schema}.execution_payload_state"
11483 )))
11484 .fetch_one(&admin_pool)
11485 .await
11486 .unwrap();
11487 assert_eq!(operation, "migrate");
11488 assert!(row.raw_transaction.is_some());
11489 assert!(row.sealed_transaction.is_none());
11490
11491 drop_execution_schema(&admin_pool, &schema).await;
11492 }
11493
11494 #[tokio::test]
11495 async fn protected_payload_migration_restart_restore_rewrap_and_rollback() {
11496 let Some((admin_pool, pg_config)) =
11497 connect_test_postgres("protected payload lifecycle").await
11498 else {
11499 return;
11500 };
11501 let schema = format!("protected_payload_lifecycle_{}", std::process::id());
11502 setup_execution_schema(&admin_pool, &schema).await;
11503 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
11504 let options = options.options([("search_path", schema.clone())]);
11505 let database = connect_test_database(options.clone()).await.unwrap();
11506 database
11507 .ensure_execution_transaction_schema()
11508 .await
11509 .unwrap();
11510 let intent = reserve_test_wrap_intent(&database).await;
11511 database
11512 .assign_execution_intent_nonce(intent.id, 7)
11513 .await
11514 .unwrap();
11515 let transaction = build_eip1559_transaction(
11516 42161,
11517 7,
11518 78_000,
11519 130_000_000,
11520 10_000_000,
11521 WETH_ADDRESS,
11522 U256::from(1_u64),
11523 Bytes::from(hex::decode("d0e30db0").unwrap()),
11524 );
11525 let (transaction_hash, raw_transaction) = sign_eip1559_transaction(
11526 transaction,
11527 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
11528 )
11529 .await
11530 .unwrap();
11531 database
11532 .add_execution_transaction_hash(
11533 intent.id,
11534 42161,
11535 &transaction_hash.to_string(),
11536 &raw_transaction,
11537 )
11538 .await
11539 .unwrap();
11540 let keys = payload_test_keys([0x41; 32], vec![], "restore-a");
11541
11542 database
11543 .ensure_execution_payload_storage(&keys)
11544 .await
11545 .unwrap();
11546 let operation: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11547 "SELECT operation FROM {schema}.execution_payload_state"
11548 )))
11549 .fetch_one(&admin_pool)
11550 .await
11551 .unwrap();
11552 assert_eq!(operation, "ready");
11553 drop(database);
11554
11555 let restarted = connect_test_database(options).await.unwrap();
11556 restarted
11557 .ensure_execution_payload_storage(&keys)
11558 .await
11559 .unwrap();
11560 let row = restarted
11561 .get_execution_transaction_hashes(intent.id)
11562 .await
11563 .unwrap()
11564 .pop()
11565 .unwrap();
11566 assert!(row.raw_transaction.is_none());
11567 let sealed_transaction = row.sealed_transaction.clone().unwrap();
11568 let stored_intent = restarted.get_execution_intent(intent.id).await.unwrap();
11569 let context = payload_context(&stored_intent, &row, keys.deployment_id()).unwrap();
11570 let alternate_envelope = keys.seal(&raw_transaction, &context).unwrap();
11571 assert_ne!(alternate_envelope, sealed_transaction);
11572 let repeated = restarted
11573 .add_execution_transaction_envelope(
11574 intent.id,
11575 42161,
11576 &transaction_hash.to_string(),
11577 &alternate_envelope,
11578 )
11579 .await
11580 .unwrap();
11581 let check = restarted
11582 .check_execution_payload_storage(Some(&keys), None, 1)
11583 .await
11584 .unwrap();
11585 assert_eq!(
11586 repeated.sealed_transaction,
11587 Some(sealed_transaction.clone())
11588 );
11589 assert_eq!(check.plaintext_rows, 0);
11590 assert_eq!(check.original_rows, 1);
11591 assert_eq!(check.replacement_rows, 0);
11592 assert_eq!(check.authenticated_rows, 1);
11593 assert!(!check.read_roles.is_empty());
11594
11595 sqlx::query(sqlx::AssertSqlSafe(format!(
11596 "UPDATE {schema}.execution_payload_key_state SET seals = 4294967295"
11597 )))
11598 .execute(&admin_pool)
11599 .await
11600 .unwrap();
11601 let exhausted = restarted
11602 .reserve_execution_payload_seal(&keys)
11603 .await
11604 .unwrap_err();
11605 assert!(exhausted.to_string().contains("seal limit"));
11606 let seals: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
11607 "SELECT seals FROM {schema}.execution_payload_key_state"
11608 )))
11609 .fetch_one(&admin_pool)
11610 .await
11611 .unwrap();
11612 assert_eq!(seals, 4_294_967_295);
11613 sqlx::query(sqlx::AssertSqlSafe(format!(
11614 "UPDATE {schema}.execution_payload_key_state SET seals = 1"
11615 )))
11616 .execute(&admin_pool)
11617 .await
11618 .unwrap();
11619
11620 let missing_key = restarted
11621 .check_execution_payload_storage(None, None, 1)
11622 .await
11623 .unwrap_err();
11624 assert!(
11625 missing_key
11626 .to_string()
11627 .contains("no payload key is configured")
11628 );
11629 let restored_elsewhere = payload_test_keys([0x41; 32], vec![], "restore-b");
11630 let wrong_context = restarted
11631 .ensure_execution_payload_storage(&restored_elsewhere)
11632 .await
11633 .unwrap_err();
11634 assert!(wrong_context.to_string().contains("deployment ID"));
11635
11636 sqlx::query(sqlx::AssertSqlSafe(format!(
11637 "UPDATE {schema}.execution_transaction_hash \
11638 SET sealed_transaction = set_byte(sealed_transaction, octet_length(sealed_transaction) - 1, \
11639 get_byte(sealed_transaction, octet_length(sealed_transaction) - 1) # 1)"
11640 )))
11641 .execute(&admin_pool)
11642 .await
11643 .unwrap();
11644 assert!(
11645 restarted
11646 .check_execution_payload_storage(Some(&keys), None, 1)
11647 .await
11648 .is_err()
11649 );
11650 sqlx::query(sqlx::AssertSqlSafe(format!(
11651 "UPDATE {schema}.execution_transaction_hash SET sealed_transaction = $1"
11652 )))
11653 .bind(&sealed_transaction)
11654 .execute(&admin_pool)
11655 .await
11656 .unwrap();
11657
11658 for statement in [
11659 format!(
11660 "ALTER TABLE {schema}.execution_transaction_hash \
11661 DROP CONSTRAINT execution_transaction_payload_protected_check"
11662 ),
11663 format!("UPDATE {schema}.execution_transaction_hash SET sealed_transaction = NULL"),
11664 ] {
11665 sqlx::query(sqlx::AssertSqlSafe(statement))
11666 .execute(&admin_pool)
11667 .await
11668 .unwrap();
11669 }
11670 assert!(
11671 restarted
11672 .check_execution_payload_storage(Some(&keys), None, 1)
11673 .await
11674 .is_err()
11675 );
11676 sqlx::query(sqlx::AssertSqlSafe(format!(
11677 "UPDATE {schema}.execution_transaction_hash SET sealed_transaction = $1"
11678 )))
11679 .bind(&sealed_transaction)
11680 .execute(&admin_pool)
11681 .await
11682 .unwrap();
11683 sqlx::query(sqlx::AssertSqlSafe(format!(
11684 "ALTER TABLE {schema}.execution_transaction_hash \
11685 ADD CONSTRAINT execution_transaction_payload_protected_check CHECK ( \
11686 (payload_expected AND raw_transaction IS NULL AND sealed_transaction IS NOT NULL) \
11687 OR (NOT payload_expected AND raw_transaction IS NULL AND sealed_transaction IS NULL) \
11688 )"
11689 )))
11690 .execute(&admin_pool)
11691 .await
11692 .unwrap();
11693
11694 let rotated = payload_test_keys([0x52; 32], vec![[0x41; 32]], "restore-a");
11695 restarted
11696 .rewrap_execution_payload_storage(&rotated, 1)
11697 .await
11698 .unwrap();
11699 let rotated_check = restarted
11700 .check_execution_payload_storage(Some(&rotated), None, 1)
11701 .await
11702 .unwrap();
11703 assert_eq!(rotated_check.authenticated_rows, 1);
11704 assert_eq!(rotated_check.key_ids.len(), 1);
11705
11706 restarted
11707 .rollback_execution_payload_storage(&rotated, 1)
11708 .await
11709 .unwrap();
11710 let rolled_back = restarted
11711 .get_execution_transaction_hashes(intent.id)
11712 .await
11713 .unwrap()
11714 .pop()
11715 .unwrap();
11716 assert_eq!(
11717 rolled_back.raw_transaction.as_deref(),
11718 Some(raw_transaction.as_slice())
11719 );
11720 assert!(rolled_back.sealed_transaction.is_none());
11721 let legacy_check = restarted
11722 .check_execution_payload_storage(None, None, 1)
11723 .await
11724 .unwrap();
11725 assert!(!legacy_check.protected);
11726 assert_eq!(legacy_check.plaintext_rows, 1);
11727 assert_eq!(legacy_check.authenticated_rows, 1);
11728
11729 drop_execution_schema(&admin_pool, &schema).await;
11730 }
11731
11732 #[tokio::test]
11733 async fn protected_payload_storage_authenticates_multiple_execution_identities() {
11734 let Some((admin_pool, pg_config)) =
11735 connect_test_postgres("protected payload multiple identities").await
11736 else {
11737 return;
11738 };
11739 let schema = format!("protected_payload_identities_{}", std::process::id());
11740 setup_execution_schema(&admin_pool, &schema).await;
11741 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
11742 let database = connect_test_database(options.options([("search_path", schema.clone())]))
11743 .await
11744 .unwrap();
11745 database
11746 .ensure_execution_transaction_schema()
11747 .await
11748 .unwrap();
11749
11750 let private_keys = [
11751 TEST_PRIVATE_KEY,
11752 "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
11753 ];
11754
11755 for (index, private_key) in private_keys.into_iter().enumerate() {
11756 let signer = PrivateKeySigner::from_str(private_key).unwrap();
11757 let nonce = 7 + u64::try_from(index).unwrap();
11758 let intent =
11759 reserve_test_wrap_intent_for_wallet(&database, &signer.address().to_string()).await;
11760 database
11761 .assign_execution_intent_nonce(intent.id, nonce)
11762 .await
11763 .unwrap();
11764 let transaction = build_eip1559_transaction(
11765 42161,
11766 nonce,
11767 78_000,
11768 130_000_000,
11769 10_000_000,
11770 WETH_ADDRESS,
11771 U256::from(1_u64),
11772 Bytes::from(hex::decode("d0e30db0").unwrap()),
11773 );
11774 let (transaction_hash, raw_transaction) =
11775 sign_eip1559_transaction(transaction, &signer)
11776 .await
11777 .unwrap();
11778 database
11779 .add_execution_transaction_hash(
11780 intent.id,
11781 42161,
11782 &transaction_hash.to_string(),
11783 &raw_transaction,
11784 )
11785 .await
11786 .unwrap();
11787 }
11788
11789 let keys = payload_test_keys([0x61; 32], vec![], "multiple-identities");
11790 database
11791 .ensure_execution_payload_storage(&keys)
11792 .await
11793 .unwrap();
11794 let lease = database
11795 .require_execution_payload_storage(
11796 &keys,
11797 PayloadPolicy {
11798 chain_id: 42161,
11799 signer: Address::from_str(WALLET).unwrap(),
11800 gas_limit: 1_000_000,
11801 max_fee_per_gas: 1_000_000_000,
11802 },
11803 1,
11804 )
11805 .await
11806 .unwrap();
11807 drop(lease);
11808 let check = database
11809 .check_execution_payload_storage(Some(&keys), None, 1)
11810 .await
11811 .unwrap();
11812
11813 assert!(check.protected);
11814 assert_eq!(check.plaintext_rows, 0);
11815 assert_eq!(check.original_rows, 2);
11816 assert_eq!(check.replacement_rows, 0);
11817 assert_eq!(check.authenticated_rows, 2);
11818 assert_eq!(check.key_ids.len(), 1);
11819
11820 drop_execution_schema(&admin_pool, &schema).await;
11821 }
11822
11823 #[rstest]
11824 #[case::finalized(TransactionStatus::Finalized)]
11825 #[case::reverted(TransactionStatus::Reverted)]
11826 #[tokio::test]
11827 async fn protected_payload_storage_ignores_current_policy_for_released_terminal_history(
11828 #[case] status: TransactionStatus,
11829 ) {
11830 let Some((admin_pool, pg_config)) =
11831 connect_test_postgres("protected payload terminal history").await
11832 else {
11833 return;
11834 };
11835 let schema = format!(
11836 "protected_payload_terminal_{}_{}",
11837 status.as_str(),
11838 std::process::id()
11839 );
11840 setup_execution_schema(&admin_pool, &schema).await;
11841 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
11842 let database = connect_test_database(options.options([("search_path", schema.clone())]))
11843 .await
11844 .unwrap();
11845 database
11846 .ensure_execution_transaction_schema()
11847 .await
11848 .unwrap();
11849 let (intent, transaction_hash, _) = persist_test_wrap_broadcast(&database, None).await;
11850 database
11851 .record_execution_status(
11852 intent.id,
11853 &transaction_hash.to_string(),
11854 status,
11855 None,
11856 None,
11857 None,
11858 None,
11859 None,
11860 )
11861 .await
11862 .unwrap();
11863 database
11864 .mark_execution_event_emitted(intent.id, "terminal")
11865 .await
11866 .unwrap();
11867 let keys = payload_test_keys([0x62; 32], vec![], "terminal-history");
11868 database
11869 .ensure_execution_payload_storage(&keys)
11870 .await
11871 .unwrap();
11872
11873 let lease = database
11874 .require_execution_payload_storage(
11875 &keys,
11876 PayloadPolicy {
11877 chain_id: 42161,
11878 signer: Address::from_str(WALLET).unwrap(),
11879 gas_limit: 1,
11880 max_fee_per_gas: 1,
11881 },
11882 1,
11883 )
11884 .await
11885 .unwrap();
11886 drop(lease);
11887 let check = database
11888 .check_execution_payload_storage(Some(&keys), None, 1)
11889 .await
11890 .unwrap();
11891 let active_intent = reserve_test_wrap_intent(&database).await;
11892 database
11893 .assign_execution_intent_nonce(active_intent.id, 8)
11894 .await
11895 .unwrap();
11896 let active_intent = database
11897 .get_execution_intent(active_intent.id)
11898 .await
11899 .unwrap();
11900 let active_transaction = build_eip1559_transaction(
11901 42161,
11902 8,
11903 78_000,
11904 130_000_000,
11905 10_000_000,
11906 WETH_ADDRESS,
11907 U256::from(1_u64),
11908 Bytes::from(hex::decode("d0e30db0").unwrap()),
11909 );
11910 let (active_hash, active_raw_transaction) = sign_eip1559_transaction(
11911 active_transaction,
11912 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
11913 )
11914 .await
11915 .unwrap();
11916 persist_test_payload(
11917 &database,
11918 Some(&keys),
11919 &active_intent,
11920 active_hash,
11921 &active_raw_transaction,
11922 )
11923 .await;
11924 let active_error = match database
11925 .require_execution_payload_storage(
11926 &keys,
11927 PayloadPolicy {
11928 chain_id: 42161,
11929 signer: Address::from_str(WALLET).unwrap(),
11930 gas_limit: 1,
11931 max_fee_per_gas: 1,
11932 },
11933 1,
11934 )
11935 .await
11936 {
11937 Ok(_) => panic!("active execution payload unexpectedly passed current policy"),
11938 Err(e) => e,
11939 };
11940 let active_error = format!("{active_error:#}");
11941
11942 assert!(check.protected);
11943 assert_eq!(check.authenticated_rows, 1);
11944 assert!(
11945 active_error.contains(&format!(
11946 "execution intent {} transaction {active_hash} violates current execution policy",
11947 active_intent.id
11948 )),
11949 "was: {active_error}"
11950 );
11951 assert!(
11952 active_error.contains("gas limit 78000 exceeds configured ceiling 1"),
11953 "was: {active_error}"
11954 );
11955
11956 drop_execution_schema(&admin_pool, &schema).await;
11957 }
11958
11959 #[allow(unsafe_code)] #[tokio::test]
11961 async fn rollback_blocks_execution_until_protection_and_full_check_succeed() {
11962 let state = ready_rpc_state();
11963 let Some((admin_pool, schema, mut client, state)) =
11964 execution_client_with_database("payload_rollback_reactivation", state).await
11965 else {
11966 return;
11967 };
11968 unsafe { std::env::set_var("BLOCKCHAIN_TEST_PRIVATE_KEY", TEST_PRIVATE_KEY) };
11970 client.disconnect().await.unwrap();
11971 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
11972 replace_exec_event_sender(sender);
11973 client.start().unwrap();
11974
11975 client.rollback_payload_storage(1).await.unwrap();
11976 let unprotected = client.check_payload_storage(1).await.unwrap();
11977 let error = client.connect().await.unwrap_err();
11978
11979 assert!(!unprotected.protected);
11980 assert_eq!(unprotected.plaintext_rows, 0);
11981 assert_eq!(unprotected.authenticated_rows, 0);
11982 assert!(
11983 error
11984 .to_string()
11985 .contains("Postgres execution requires protected payload storage"),
11986 "was: {error}"
11987 );
11988 assert!(client.signer.is_none());
11989 assert!(state.recorded_requests().is_empty());
11990
11991 client.protect_payload_storage().await.unwrap();
11992 let protected = client.check_payload_storage(1).await.unwrap();
11993 assert!(protected.protected);
11994 assert_eq!(protected.plaintext_rows, 0);
11995 assert_eq!(protected.authenticated_rows, 0);
11996
11997 client.connect().await.unwrap();
11998
11999 assert!(client.is_connected());
12000 assert!(client.transaction_executor().is_ok());
12001 assert!(!state.recorded_requests().is_empty());
12002
12003 drop(client);
12004 drop_execution_schema(&admin_pool, &schema).await;
12005 }
12006
12007 #[rstest]
12008 #[case::active_key(true)]
12009 #[case::deployment_identity(false)]
12010 #[tokio::test]
12011 async fn postgres_connect_rejects_missing_payload_identity_before_rpc(
12012 #[case] remove_active_key: bool,
12013 ) {
12014 let test_name = if remove_active_key {
12015 "payload_connect_missing_active_key"
12016 } else {
12017 "payload_connect_missing_deployment"
12018 };
12019 let Some((admin_pool, schema, mut client, state)) =
12020 execution_client_with_database(test_name, ready_rpc_state()).await
12021 else {
12022 return;
12023 };
12024 client.disconnect().await.unwrap();
12025 client.payload_keys = None;
12026 if remove_active_key {
12027 client.config.payload_key_env = None;
12028 client.config.payload_deployment_id = None;
12029 } else {
12030 client.config.payload_deployment_id = None;
12031 }
12032
12033 let error = client.connect().await.unwrap_err();
12034
12035 assert!(
12036 error.to_string().contains(if remove_active_key {
12037 "Postgres execution requires an active payload key and deployment identity"
12038 } else {
12039 "Payload deployment ID is required"
12040 }),
12041 "was: {error}"
12042 );
12043 assert!(client.signer.is_none());
12044 assert!(client.payload_keys.is_none());
12045 assert!(state.recorded_requests().is_empty());
12046
12047 drop(client);
12048 drop_execution_schema(&admin_pool, &schema).await;
12049 }
12050
12051 #[tokio::test]
12052 async fn postgres_connect_rejects_missing_envelope_key_before_rpc() {
12053 let Some((admin_pool, schema, mut client, state)) = execution_client_with_database(
12054 "payload_connect_missing_envelope_key",
12055 ready_rpc_state(),
12056 )
12057 .await
12058 else {
12059 return;
12060 };
12061 let database = client.cache.database.as_ref().unwrap().clone();
12062 persist_test_wrap_broadcast(&database, client.payload_keys.as_deref()).await;
12063 client.disconnect().await.unwrap();
12064 client.payload_keys = None;
12065 let keys = set_test_payload_key(&mut client, [0xb6; 32], &schema);
12066 sqlx::query(sqlx::AssertSqlSafe(format!(
12067 "UPDATE {schema}.execution_payload_state SET active_key_id = $1 \
12068 WHERE component = 'signed_transactions'"
12069 )))
12070 .bind(keys.active_key_id().as_slice())
12071 .execute(&admin_pool)
12072 .await
12073 .unwrap();
12074
12075 let error = client.connect().await.unwrap_err();
12076
12077 assert!(
12078 error
12079 .to_string()
12080 .contains("Stored execution payload requires unavailable key"),
12081 "was: {error}"
12082 );
12083 assert!(client.signer.is_none());
12084 assert!(client.payload_keys.is_none());
12085 assert!(state.recorded_requests().is_empty());
12086
12087 drop(client);
12088 drop_execution_schema(&admin_pool, &schema).await;
12089 }
12090
12091 #[tokio::test]
12092 async fn payload_action_lease_blocks_rewrap_transition_until_release() {
12093 let Some((admin_pool, schema, client, _)) = execution_client_with_unprotected_database(
12094 "payload_action_lease",
12095 execution_rpc_state(),
12096 )
12097 .await
12098 else {
12099 return;
12100 };
12101 let database = client.cache.database.as_ref().unwrap();
12102 let keys = payload_test_keys([0x71; 32], vec![], "action-lease");
12103 database
12104 .ensure_execution_payload_storage(&keys)
12105 .await
12106 .unwrap();
12107 let lease = database
12108 .acquire_execution_payload_lease(&keys)
12109 .await
12110 .unwrap();
12111 let rotated = payload_test_keys([0x72; 32], vec![[0x71; 32]], "action-lease");
12112 let mut operation = Box::pin(database.rewrap_execution_payload_storage(&rotated, 1));
12113
12114 assert!(
12115 tokio::time::timeout(Duration::from_millis(50), &mut operation)
12116 .await
12117 .is_err()
12118 );
12119 let state: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
12120 "SELECT operation FROM {schema}.execution_payload_state"
12121 )))
12122 .fetch_one(&admin_pool)
12123 .await
12124 .unwrap();
12125 assert_eq!(state, "ready");
12126
12127 drop(lease);
12128 tokio::time::timeout(Duration::from_secs(2), operation)
12129 .await
12130 .unwrap()
12131 .unwrap();
12132 let state: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
12133 "SELECT operation FROM {schema}.execution_payload_state"
12134 )))
12135 .fetch_one(&admin_pool)
12136 .await
12137 .unwrap();
12138 assert_eq!(state, "ready");
12139
12140 drop_execution_schema(&admin_pool, &schema).await;
12141 }
12142
12143 #[tokio::test]
12144 async fn payload_infrastructure_checks_are_scoped_to_the_execution_schema() {
12145 let Some((admin_pool, pg_config)) = connect_test_postgres("payload schema isolation").await
12146 else {
12147 return;
12148 };
12149 let schema_a = format!("payload_schema_a_{}", std::process::id());
12150 let schema_b = format!("payload_schema_b_{}", std::process::id());
12151 setup_execution_schema(&admin_pool, &schema_a).await;
12152 setup_execution_schema(&admin_pool, &schema_b).await;
12153 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
12154 let database_a =
12155 connect_test_database(options.clone().options([("search_path", schema_a.clone())]))
12156 .await
12157 .unwrap();
12158 let database_b =
12159 connect_test_database(options.options([("search_path", schema_b.clone())]))
12160 .await
12161 .unwrap();
12162 database_a
12163 .ensure_execution_transaction_schema()
12164 .await
12165 .unwrap();
12166 database_b
12167 .ensure_execution_transaction_schema()
12168 .await
12169 .unwrap();
12170 let keys_a = payload_test_keys([0x91; 32], vec![], "schema-a");
12171 let keys_b = payload_test_keys([0x92; 32], vec![], "schema-b");
12172
12173 database_a
12174 .ensure_execution_payload_storage(&keys_a)
12175 .await
12176 .unwrap();
12177 database_b
12178 .ensure_execution_payload_storage(&keys_b)
12179 .await
12180 .unwrap();
12181
12182 for statement in [
12183 format!(
12184 "DROP TRIGGER execution_transaction_payload_fence ON \
12185 {schema_a}.execution_transaction_hash"
12186 ),
12187 format!(
12188 "ALTER TABLE {schema_a}.execution_transaction_hash \
12189 DROP CONSTRAINT execution_transaction_payload_protected_check"
12190 ),
12191 ] {
12192 sqlx::query(sqlx::AssertSqlSafe(statement))
12193 .execute(&admin_pool)
12194 .await
12195 .unwrap();
12196 }
12197
12198 let error = database_a
12199 .ensure_execution_payload_storage(&keys_a)
12200 .await
12201 .unwrap_err();
12202
12203 assert!(
12204 error
12205 .to_string()
12206 .contains("without its write fence or constraint"),
12207 "was: {error}"
12208 );
12209
12210 drop_execution_schema(&admin_pool, &schema_a).await;
12211 drop_execution_schema(&admin_pool, &schema_b).await;
12212 }
12213
12214 #[tokio::test]
12215 async fn execution_fresh_schema_preserves_nullable_wallet_address() {
12216 let Some((admin_pool, _)) = connect_test_postgres("fresh execution schema").await else {
12217 return;
12218 };
12219 let schema = format!("execution_fresh_test_{}", std::process::id());
12220 let mut transaction = admin_pool.begin().await.unwrap();
12221 sqlx::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {schema}")))
12222 .execute(&mut *transaction)
12223 .await
12224 .unwrap();
12225 sqlx::query(sqlx::AssertSqlSafe(format!(
12226 "SET LOCAL search_path TO {schema}"
12227 )))
12228 .execute(&mut *transaction)
12229 .await
12230 .unwrap();
12231 sqlx::query("CREATE TABLE chain (chain_id INTEGER PRIMARY KEY)")
12232 .execute(&mut *transaction)
12233 .await
12234 .unwrap();
12235 sqlx::query("INSERT INTO chain (chain_id) VALUES (42161)")
12236 .execute(&mut *transaction)
12237 .await
12238 .unwrap();
12239 sqlx::query(sqlx::AssertSqlSafe(execution_transaction_create_sql()))
12240 .execute(&mut *transaction)
12241 .await
12242 .unwrap();
12243 sqlx::query(
12244 "INSERT INTO execution_transaction \
12245 (chain_id, nonce, transaction_hash, purpose, status) \
12246 VALUES (42161, 7, '0xfresh-legacy', 'wrap', 'rejected')",
12247 )
12248 .execute(&mut *transaction)
12249 .await
12250 .unwrap();
12251
12252 let is_nullable: String = sqlx::query_scalar(
12253 "SELECT is_nullable FROM information_schema.columns \
12254 WHERE table_schema = $1 AND table_name = 'execution_transaction' \
12255 AND column_name = 'wallet_address'",
12256 )
12257 .bind(&schema)
12258 .fetch_one(&mut *transaction)
12259 .await
12260 .unwrap();
12261 let wallet_address: Option<String> = sqlx::query_scalar(
12262 "SELECT wallet_address FROM execution_transaction \
12263 WHERE transaction_hash = '0xfresh-legacy'",
12264 )
12265 .fetch_one(&mut *transaction)
12266 .await
12267 .unwrap();
12268
12269 assert_eq!(is_nullable, "YES");
12270 assert_eq!(wallet_address, None);
12271 transaction.rollback().await.unwrap();
12272 }
12273
12274 #[tokio::test]
12275 async fn execution_schema_migration_preserves_existing_rows() {
12276 let Some((admin_pool, pg_config)) =
12277 connect_test_postgres("execution schema migration").await
12278 else {
12279 return;
12280 };
12281 let schema = format!("execution_migration_test_{}", std::process::id());
12282 setup_execution_schema(&admin_pool, &schema).await;
12283 sqlx::query(sqlx::AssertSqlSafe(format!(
12284 "INSERT INTO {schema}.execution_transaction \
12285 (chain_id, nonce, transaction_hash, purpose, status) \
12286 VALUES (42161, 7, '0xlegacy', 'wrap', 'rejected')"
12287 )))
12288 .execute(&admin_pool)
12289 .await
12290 .unwrap();
12291
12292 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
12293 let db_options = db_options.options([("search_path", schema.clone())]);
12294 let database = connect_test_database(db_options).await.unwrap();
12295 database
12296 .ensure_execution_transaction_schema()
12297 .await
12298 .unwrap();
12299 let legacy = database
12300 .get_execution_transaction(42161, "0xlegacy")
12301 .await
12302 .unwrap()
12303 .unwrap();
12304 assert_eq!(legacy.purpose, "wrap");
12305 assert_eq!(legacy.status, "rejected");
12306 assert_eq!(legacy.client_order_id, None);
12307 assert_eq!(legacy.wallet_address, None);
12308 let is_nullable: String = sqlx::query_scalar(
12309 "SELECT is_nullable FROM information_schema.columns \
12310 WHERE table_schema = $1 AND table_name = 'execution_transaction' \
12311 AND column_name = 'wallet_address'",
12312 )
12313 .bind(&schema)
12314 .fetch_one(&admin_pool)
12315 .await
12316 .unwrap();
12317 let fence_error = database
12318 .add_execution_transaction(
12319 42161,
12320 WALLET,
12321 8,
12322 "0xswap",
12323 "swap",
12324 "pending",
12325 Some("O-SWAP-001"),
12326 )
12327 .await
12328 .err()
12329 .unwrap();
12330 assert!(
12331 fence_error
12332 .to_string()
12333 .contains("Legacy execution writer refused"),
12334 "was: {fence_error}"
12335 );
12336 assert_eq!(is_nullable, "YES");
12337
12338 drop_execution_schema(&admin_pool, &schema).await;
12339 }
12340
12341 #[tokio::test]
12342 async fn execution_schema_migration_drops_legacy_wallet_not_null() {
12343 let Some((admin_pool, pg_config)) =
12344 connect_test_postgres("legacy wallet address schema migration").await
12345 else {
12346 return;
12347 };
12348 let schema = format!("execution_wallet_migration_test_{}", std::process::id());
12349 setup_execution_schema(&admin_pool, &schema).await;
12350 sqlx::query(sqlx::AssertSqlSafe(format!(
12351 "ALTER TABLE {schema}.execution_transaction \
12352 ADD COLUMN wallet_address TEXT NOT NULL"
12353 )))
12354 .execute(&admin_pool)
12355 .await
12356 .unwrap();
12357 sqlx::query(sqlx::AssertSqlSafe(format!(
12358 "INSERT INTO {schema}.execution_transaction \
12359 (chain_id, wallet_address, nonce, transaction_hash, purpose, status) \
12360 VALUES (42161, '{WALLET}', 7, '0xlegacy-wallet', 'wrap', 'rejected')"
12361 )))
12362 .execute(&admin_pool)
12363 .await
12364 .unwrap();
12365
12366 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
12367 let db_options = db_options.options([("search_path", schema.clone())]);
12368 let database = connect_test_database(db_options).await.unwrap();
12369 database
12370 .ensure_execution_transaction_schema()
12371 .await
12372 .unwrap();
12373 let legacy = database
12374 .get_execution_transaction(42161, "0xlegacy-wallet")
12375 .await
12376 .unwrap()
12377 .unwrap();
12378 let is_nullable: String = sqlx::query_scalar(
12379 "SELECT is_nullable FROM information_schema.columns \
12380 WHERE table_schema = $1 AND table_name = 'execution_transaction' \
12381 AND column_name = 'wallet_address'",
12382 )
12383 .bind(&schema)
12384 .fetch_one(&admin_pool)
12385 .await
12386 .unwrap();
12387
12388 assert_eq!(legacy.wallet_address.as_deref(), Some(WALLET));
12389 assert_eq!(is_nullable, "YES");
12390 drop_execution_schema(&admin_pool, &schema).await;
12391 }
12392
12393 #[tokio::test]
12394 async fn execution_schema_migration_refuses_unresolved_legacy_rows() {
12395 let Some((admin_pool, pg_config)) =
12396 connect_test_postgres("unsafe execution schema migration").await
12397 else {
12398 return;
12399 };
12400 let schema = format!("execution_unsafe_migration_test_{}", std::process::id());
12401 setup_execution_schema(&admin_pool, &schema).await;
12402 sqlx::query(sqlx::AssertSqlSafe(format!(
12403 "INSERT INTO {schema}.execution_transaction \
12404 (chain_id, nonce, transaction_hash, purpose, status) \
12405 VALUES (42161, 7, '0xunresolved', 'wrap', 'pending')"
12406 )))
12407 .execute(&admin_pool)
12408 .await
12409 .unwrap();
12410 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
12411 let db_options = db_options.options([("search_path", schema.clone())]);
12412 let database = connect_test_database(db_options).await.unwrap();
12413
12414 let error = database
12415 .ensure_execution_transaction_schema()
12416 .await
12417 .unwrap_err();
12418 let legacy_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
12419 "SELECT COUNT(*) FROM {schema}.execution_transaction \
12420 WHERE transaction_hash = '0xunresolved' AND status = 'pending'"
12421 )))
12422 .fetch_one(&admin_pool)
12423 .await
12424 .unwrap();
12425 let v2_table: Option<String> = sqlx::query_scalar("SELECT to_regclass($1)::TEXT")
12426 .bind(format!("{schema}.execution_intent"))
12427 .fetch_one(&admin_pool)
12428 .await
12429 .unwrap();
12430
12431 assert!(
12432 error
12433 .to_string()
12434 .contains("Cannot safely migrate 1 unresolved execution schema version 1"),
12435 "was: {error}"
12436 );
12437 assert_eq!(legacy_count, 1);
12438 assert_eq!(v2_table, None);
12439
12440 drop_execution_schema(&admin_pool, &schema).await;
12441 }
12442
12443 #[rstest]
12444 fn receipt_max_polls_derives_from_timeout() {
12445 assert_eq!(receipt_max_polls(0), 1);
12446 assert_eq!(receipt_max_polls(1), 1);
12447 assert_eq!(receipt_max_polls(60), 60);
12448 assert_eq!(receipt_max_polls(u64::MAX), u32::MAX);
12449 assert_eq!(receipt_timeout(0), Duration::from_secs(1));
12450 assert_eq!(receipt_timeout(60), Duration::from_secs(60));
12451 assert_eq!(
12452 receipt_timeout(u64::MAX),
12453 Duration::from_secs(u64::from(u32::MAX))
12454 );
12455 }
12456
12457 #[rstest]
12458 fn submit_order_errors_when_order_not_cached() {
12459 let client = swap_client_with_cache(test_config("http://127.0.0.1:1".to_string())).0;
12460 let mut cmd = submit_order_cmd(&test_market_sell_order(test_pool().instrument_id));
12461 cmd.client_order_id = ClientOrderId::from("O-UNKNOWN");
12462
12463 let error = client.submit_order(cmd).unwrap_err();
12464
12465 assert!(!error.to_string().is_empty());
12466 }
12467
12468 #[tokio::test]
12469 async fn submit_order_denies_pool_fee_above_uint24() {
12470 let cache = Rc::new(RefCell::new(Cache::default()));
12471 let mut pool = test_pool();
12472 pool.fee = Some(16_777_216); cache.borrow_mut().add_pool(pool.clone()).unwrap();
12474 let order = test_market_sell_order(pool.instrument_id);
12475 cache
12476 .borrow_mut()
12477 .add_order(order.clone(), None, None, false)
12478 .unwrap();
12479 let core = ExecutionClientCore::new(
12480 TraderId::from("TRADER-001"),
12481 ClientId::from("BLOCKCHAIN-001"),
12482 *BLOCKCHAIN_VENUE,
12483 OmsType::Netting,
12484 AccountId::from("BLOCKCHAIN-001"),
12485 AccountType::Wallet,
12486 None,
12487 cache,
12488 );
12489 let mut client =
12490 BlockchainExecutionClient::new(core, test_config("http://127.0.0.1:1".to_string()))
12491 .unwrap();
12492 let mut receiver = start_with_events(&mut client);
12493
12494 client.submit_order(submit_order_cmd(&order)).unwrap();
12495
12496 let events = collect_order_events(&mut receiver);
12497 assert_eq!(events.len(), 1);
12498 let OrderEventAny::Denied(denied) = &events[0] else {
12499 panic!("expected OrderDenied, was {:?}", events[0]);
12500 };
12501 assert!(
12502 denied.reason.contains("exceeds uint24"),
12503 "was: {}",
12504 denied.reason
12505 );
12506 }
12507
12508 #[tokio::test]
12509 async fn submit_order_denies_uninitialized_profiler() {
12510 let cache = Rc::new(RefCell::new(Cache::default()));
12511 let pool = test_pool();
12512 cache.borrow_mut().add_pool(pool.clone()).unwrap();
12513 let order = test_market_sell_order(pool.instrument_id);
12514 cache
12515 .borrow_mut()
12516 .add_order(order.clone(), None, None, false)
12517 .unwrap();
12518 cache
12519 .borrow_mut()
12520 .add_pool_profiler(PoolProfiler::new(Arc::new(pool)))
12521 .unwrap();
12522 let core = ExecutionClientCore::new(
12523 TraderId::from("TRADER-001"),
12524 ClientId::from("BLOCKCHAIN-001"),
12525 *BLOCKCHAIN_VENUE,
12526 OmsType::Netting,
12527 AccountId::from("BLOCKCHAIN-001"),
12528 AccountType::Wallet,
12529 None,
12530 cache,
12531 );
12532 let mut client =
12533 BlockchainExecutionClient::new(core, test_config("http://127.0.0.1:1".to_string()))
12534 .unwrap();
12535 let mut receiver = start_with_events(&mut client);
12536
12537 client.submit_order(submit_order_cmd(&order)).unwrap();
12538
12539 let events = collect_order_events(&mut receiver);
12540 assert_eq!(events.len(), 1);
12541 let OrderEventAny::Denied(denied) = &events[0] else {
12542 panic!("expected OrderDenied, was {:?}", events[0]);
12543 };
12544 assert!(
12545 denied.reason.contains("is not initialized"),
12546 "was: {}",
12547 denied.reason
12548 );
12549 }
12550
12551 #[tokio::test]
12552 async fn submit_order_denies_when_quote_cannot_fill_order() {
12553 let cache = Rc::new(RefCell::new(Cache::default()));
12554 let pool = test_pool();
12555 cache.borrow_mut().add_pool(pool.clone()).unwrap();
12556 let order = test_market_sell_order(pool.instrument_id);
12557 cache
12558 .borrow_mut()
12559 .add_order(order.clone(), None, None, false)
12560 .unwrap();
12561 cache
12563 .borrow_mut()
12564 .add_pool_profiler(test_profiler_with_range(
12565 &pool,
12566 FIXTURE_BLOCK,
12567 FIXTURE_BLOCK_HASH,
12568 U160::from(1u128 << 96),
12569 -10,
12570 10,
12571 1,
12572 ))
12573 .unwrap();
12574 let core = ExecutionClientCore::new(
12575 TraderId::from("TRADER-001"),
12576 ClientId::from("BLOCKCHAIN-001"),
12577 *BLOCKCHAIN_VENUE,
12578 OmsType::Netting,
12579 AccountId::from("BLOCKCHAIN-001"),
12580 AccountType::Wallet,
12581 None,
12582 cache,
12583 );
12584 let mut client =
12585 BlockchainExecutionClient::new(core, test_config("http://127.0.0.1:1".to_string()))
12586 .unwrap();
12587 let mut receiver = start_with_events(&mut client);
12588
12589 client.submit_order(submit_order_cmd(&order)).unwrap();
12590
12591 let events = collect_order_events(&mut receiver);
12592 assert_eq!(events.len(), 1);
12593 let OrderEventAny::Denied(denied) = &events[0] else {
12594 panic!("expected OrderDenied, was {:?}", events[0]);
12595 };
12596 assert!(
12597 denied.reason.contains("cannot fill the order"),
12598 "was: {}",
12599 denied.reason
12600 );
12601 }
12602
12603 #[tokio::test]
12604 async fn submit_order_applies_slippage_param_override_at_ceiling() {
12605 let Some((admin_pool, schema, mut client, state, _)) = swap_client_with_database(
12606 "execution_submit_slippage_override_test",
12607 swap_rpc_state_with_min_amount_out(expected_min_amount_out(200)).await,
12608 )
12609 .await
12610 else {
12611 return;
12612 };
12613 let order = test_market_sell_order(test_pool().instrument_id);
12614 let mut cmd = submit_order_cmd(&order);
12615 cmd.params = Some(serde_json::from_str(r#"{"slippage_bps": 200}"#).unwrap());
12617 let mut receiver = start_with_events(&mut client);
12618 let expected_min_out = expected_min_amount_out(200);
12619
12620 client.submit_order(cmd).unwrap();
12621 await_pending_tasks(&client).await;
12622
12623 let events = collect_order_events(&mut receiver);
12624 assert_swap_submitted_and_filled(&events);
12625
12626 let (_, expected_raw) = expected_swap_tx(expected_min_out).await;
12627 let broadcasts: Vec<_> = state
12628 .recorded_requests()
12629 .into_iter()
12630 .filter(|request| request["method"] == "eth_sendRawTransaction")
12631 .collect();
12632 assert_eq!(broadcasts.len(), 1);
12633 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
12634
12635 drop_execution_schema(&admin_pool, &schema).await;
12636 }
12637
12638 #[tokio::test]
12639 async fn submit_order_accepts_quote_fresh_at_max_age_boundary() {
12640 let min_amount_out = expected_min_amount_out(50);
12641 let (tx_hash, _) = expected_swap_tx(min_amount_out).await;
12642 let head = finalized_swap_block(tx_hash, min_amount_out);
12643 let state = swap_rpc_state()
12644 .await
12645 .with_response("eth_getBlockByNumber", &head)
12646 .with_parameter_response("eth_getBlockByNumber", FIXTURE_BLOCK_PARAM, BLOCK_BY_NUMBER);
12647 let Some((admin_pool, schema, mut client, state, cache)) =
12648 swap_client_with_database_config(
12649 "execution_submit_fresh_boundary_test",
12650 state,
12651 |http_rpc_url| {
12652 let mut config = test_config(http_rpc_url);
12653 config.max_quote_age_blocks = Some(1);
12654 config
12655 },
12656 )
12657 .await
12658 else {
12659 return;
12660 };
12661 let pool = test_pool();
12662 cache
12663 .borrow_mut()
12664 .add_pool_profiler(test_profiler_at_block(
12665 &pool,
12666 FIXTURE_BLOCK,
12667 FIXTURE_BLOCK_HASH,
12668 ))
12669 .unwrap();
12670 let order = test_market_sell_order(pool.instrument_id);
12671 let mut receiver = start_with_events(&mut client);
12672
12673 client.submit_order(submit_order_cmd(&order)).unwrap();
12674 await_pending_tasks(&client).await;
12675
12676 let events = collect_order_events(&mut receiver);
12677 assert_eq!(events.len(), 1, "was: {events:?}");
12678 assert!(matches!(&events[0], OrderEventAny::Submitted(_)));
12679 assert_eq!(
12680 state
12681 .recorded_requests()
12682 .iter()
12683 .filter(|request| request["method"] == "eth_sendRawTransaction")
12684 .count(),
12685 1
12686 );
12687
12688 drop_execution_schema(&admin_pool, &schema).await;
12689 }
12690
12691 #[tokio::test]
12692 async fn submit_order_sells_base_token_from_token1_position() {
12693 let chain = Arc::new(chains::ARBITRUM.clone());
12697 let dex = UNISWAP_V3.dex.clone();
12698 let usdc = Token::new(
12699 chain.clone(),
12700 address!("af88d065e77c8cC2239327C5EDb3A432268e5831"),
12701 "USD Coin".to_string(),
12702 "USDC".to_string(),
12703 6,
12704 );
12705 let weth = Token::new(
12706 chain.clone(),
12707 address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
12708 "Wrapped Ether".to_string(),
12709 "WETH".to_string(),
12710 18,
12711 );
12712 let pool = Pool::new(
12713 chain,
12714 dex,
12715 address!("C6962004f452bE9203591991D15f6b388e09E8D0"),
12716 PoolIdentifier::from_address(address!("C6962004f452bE9203591991D15f6b388e09E8D0")),
12717 55_000_000,
12718 usdc,
12719 weth,
12720 Some(500),
12721 Some(10),
12722 UnixNanos::default(),
12723 );
12724
12725 let cache = Rc::new(RefCell::new(Cache::default()));
12726 cache.borrow_mut().add_pool(pool.clone()).unwrap();
12727 let order = test_market_sell_order(pool.instrument_id);
12728 cache
12729 .borrow_mut()
12730 .add_order(order.clone(), None, None, false)
12731 .unwrap();
12732 cache
12734 .borrow_mut()
12735 .add_pool_profiler(test_profiler_with_state(
12736 &pool,
12737 FIXTURE_BLOCK,
12738 U160::from(2u128 << 96),
12739 TEST_LIQUIDITY,
12740 ))
12741 .unwrap();
12742
12743 let (admin_pool, pg_config) = match connect_test_postgres("orientation").await {
12744 Some(setup) => setup,
12745 None => return,
12746 };
12747 let schema = format!("execution_submit_orientation_test_{}", std::process::id());
12748 setup_execution_schema(&admin_pool, &schema).await;
12749 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
12750 let db_options = db_options.options([("search_path", schema.clone())]);
12751 let database = connect_test_database(db_options).await.unwrap();
12752
12753 let state = swap_rpc_state()
12754 .await
12755 .with_call_response(POOL_TOKEN0_SELECTOR, CALL_USDC)
12756 .with_call_response(POOL_TOKEN1_SELECTOR, CALL_WETH)
12757 .with_response("eth_getTransactionReceipt", RECEIPT_NULL);
12758 let addr = start_mock_rpc_server(state.clone()).await;
12759 let core = ExecutionClientCore::new(
12760 TraderId::from("TRADER-001"),
12761 ClientId::from("BLOCKCHAIN-001"),
12762 *BLOCKCHAIN_VENUE,
12763 OmsType::Netting,
12764 AccountId::from("BLOCKCHAIN-001"),
12765 AccountType::Wallet,
12766 None,
12767 cache,
12768 );
12769 let mut config = test_config(format!("http://{addr}"));
12770 let result = |response: &str| {
12771 serde_json::from_str::<serde_json::Value>(response).unwrap()["result"]
12772 .as_str()
12773 .unwrap()
12774 .to_string()
12775 };
12776 {
12777 let verification = config.verification.as_mut().unwrap();
12778 let pool_identity = &mut verification.deployment_manifest.pools[0];
12779 pool_identity.token0 = USDC.to_string();
12780 pool_identity.token1 = WETH.to_string();
12781 let pool_contract = verification
12782 .deployment_manifest
12783 .contracts
12784 .iter_mut()
12785 .find(|contract| contract.role == BlockchainContractRole::Pool)
12786 .unwrap();
12787
12788 for probe in &mut pool_contract.probes {
12789 if probe.call_data.starts_with(POOL_TOKEN0_SELECTOR) {
12790 probe.expected_output = result(CALL_USDC);
12791 } else if probe.call_data.starts_with(POOL_TOKEN1_SELECTOR) {
12792 probe.expected_output = result(CALL_WETH);
12793 }
12794 }
12795 }
12796 refresh_test_manifest_digest(&mut config);
12797 let mut client = BlockchainExecutionClient::new(core, config).unwrap();
12798 client.cache.database = Some(database);
12799 client
12800 .cache
12801 .ensure_execution_transaction_schema()
12802 .await
12803 .unwrap();
12804 protect_test_storage(&mut client, &schema).await;
12805 initialize_test_verification_ledger(&client).await;
12806 client.signer = Some(Arc::new(
12807 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
12808 ));
12809 client.core.set_connected();
12810 let mut receiver = start_with_events(&mut client);
12811
12812 let profiler = test_profiler_with_state(
12813 &pool,
12814 FIXTURE_BLOCK,
12815 U160::from(2u128 << 96),
12816 TEST_LIQUIDITY,
12817 );
12818 let quote = profiler
12819 .swap_exact_in(U256::from(1_000_000_000_000_000u64), false, None)
12820 .unwrap();
12821 let quoted = exact_output_amount("e, false).unwrap();
12822 let expected_min_out = derive_min_amount_out(quoted, 50).unwrap();
12823 let wrong_direction_quote = profiler
12824 .swap_exact_in(U256::from(1_000_000_000_000_000u64), true, None)
12825 .unwrap();
12826 let wrong_direction_out = exact_output_amount(&wrong_direction_quote, true).unwrap();
12827 assert_ne!(
12828 quoted, wrong_direction_out,
12829 "the asymmetric price must make the quote direction observable"
12830 );
12831 assert_ne!(
12832 expected_min_out,
12833 expected_min_amount_out(50),
12834 "the profiler quote must remain distinct from the independent quote fixture"
12835 );
12836
12837 client.submit_order(submit_order_cmd(&order)).unwrap();
12838 await_pending_tasks(&client).await;
12839
12840 let events = collect_order_events(&mut receiver);
12841 assert_eq!(events.len(), 1);
12842 assert!(
12843 matches!(&events[0], OrderEventAny::Submitted(_)),
12844 "was: {:?}",
12845 events[0]
12846 );
12847
12848 let (_, expected_raw) = expected_swap_tx(expected_min_amount_out(50)).await;
12849 let broadcasts: Vec<_> = state
12850 .recorded_requests()
12851 .into_iter()
12852 .filter(|request| request["method"] == "eth_sendRawTransaction")
12853 .collect();
12854 assert_eq!(broadcasts.len(), 1);
12855 assert_eq!(broadcasts[0]["params"][0].as_str().unwrap(), expected_raw);
12856
12857 drop_execution_schema(&admin_pool, &schema).await;
12858 }
12859
12860 #[tokio::test]
12861 async fn submit_order_denies_on_chain_mismatch() {
12862 let state = swap_rpc_state()
12863 .await
12864 .with_response("eth_chainId", CHAIN_ID_ETHEREUM);
12865 let Some((admin_pool, schema, mut client, state, _)) =
12866 swap_client_with_database("execution_submit_chain_mismatch_test", state).await
12867 else {
12868 return;
12869 };
12870 let order = test_market_sell_order(test_pool().instrument_id);
12871 let mut receiver = start_with_events(&mut client);
12872
12873 client.submit_order(submit_order_cmd(&order)).unwrap();
12874 await_pending_tasks(&client).await;
12875
12876 let events = collect_order_events(&mut receiver);
12877 assert_eq!(events.len(), 1);
12878 let OrderEventAny::Denied(denied) = &events[0] else {
12879 panic!("expected OrderDenied, was {:?}", events[0]);
12880 };
12881 assert!(
12882 denied
12883 .reason
12884 .contains("pre-sign chain ID verification disagreed"),
12885 "was: {}",
12886 denied.reason
12887 );
12888 let requests = state.recorded_requests();
12889 assert!(
12890 requests
12891 .iter()
12892 .all(|request| request["method"] != "eth_sendRawTransaction"),
12893 "no broadcast may follow a chain mismatch"
12894 );
12895 assert!(client.in_flight.lock().is_none());
12896
12897 drop_execution_schema(&admin_pool, &schema).await;
12898 }
12899
12900 #[tokio::test]
12901 async fn submit_order_finality_timeout_marks_dropped_and_keeps_ownership() {
12902 let receipt_release = Arc::new(tokio::sync::Semaphore::new(0));
12903 let state = swap_rpc_state()
12904 .await
12905 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
12906 .with_response_release("eth_getTransactionReceipt", Arc::clone(&receipt_release));
12907 let Some((admin_pool, schema, mut client, state, _)) =
12908 swap_client_with_database("execution_submit_inclusion_timeout_test", state).await
12909 else {
12910 return;
12911 };
12912 client.transaction_limits.receipt_timeout_secs = 1;
12913 let order = test_market_sell_order(test_pool().instrument_id);
12914 let mut receiver = start_with_events(&mut client);
12915
12916 client.submit_order(submit_order_cmd(&order)).unwrap();
12917 await_recorded_requests(&state, "eth_getTransactionReceipt", 3).await;
12918 tokio::time::timeout(Duration::from_secs(3), await_pending_tasks(&client))
12919 .await
12920 .unwrap();
12921 receipt_release.add_permits(3);
12922
12923 let events = collect_order_events(&mut receiver);
12926 assert_eq!(events.len(), 1);
12927 assert!(
12928 matches!(&events[0], OrderEventAny::Submitted(_)),
12929 "was: {:?}",
12930 events[0]
12931 );
12932
12933 let record = client
12934 .cache
12935 .get_execution_transaction(
12936 42161,
12937 &expected_swap_tx(expected_min_amount_out(50))
12938 .await
12939 .0
12940 .to_string(),
12941 )
12942 .await
12943 .unwrap()
12944 .unwrap();
12945 assert_eq!(record.status, "dropped");
12946 assert!(client.in_flight.lock().is_some());
12947
12948 drop_execution_schema(&admin_pool, &schema).await;
12949 }
12950
12951 #[tokio::test]
12952 async fn included_receipt_finality_timeout_marks_dropped_and_keeps_ownership() {
12953 let state = execution_rpc_state()
12954 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
12955 .with_response("eth_estimateGas", ESTIMATE_GAS)
12956 .with_response("eth_call", CALL_BALANCE)
12957 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS)
12958 .with_parameter_response("eth_getBlockByNumber", "finalized", BLOCK_BY_NUMBER)
12959 .with_send_raw_transaction_echo();
12960 let Some((admin_pool, schema, mut client, _)) =
12961 execution_client_with_database("execution_included_timeout_test", state).await
12962 else {
12963 return;
12964 };
12965 client.transaction_limits.receipt_timeout_secs = 1;
12966
12967 let error = client
12968 .wrap(U256::from(1_000_000_000_000_000_u64))
12969 .await
12970 .unwrap_err();
12971 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
12972 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
12973 )))
12974 .fetch_all(&admin_pool)
12975 .await
12976 .unwrap();
12977
12978 assert!(
12979 error.to_string().contains("Timed out awaiting finality"),
12980 "was: {error}"
12981 );
12982 assert_eq!(transitions, ["prepared", "signed", "broadcast", "dropped"]);
12983 assert!(client.in_flight.lock().is_some());
12984
12985 drop_execution_schema(&admin_pool, &schema).await;
12986 }
12987
12988 #[tokio::test]
12989 async fn submit_order_single_in_flight_rejects_concurrent_swap() {
12990 let broadcast_release = Arc::new(tokio::sync::Semaphore::new(0));
12991 let state = swap_rpc_state()
12992 .await
12993 .with_response_release("eth_sendRawTransaction", Arc::clone(&broadcast_release));
12994 let Some((admin_pool, schema, mut client, state, cache)) =
12995 swap_client_with_database("execution_submit_concurrent_test", state).await
12996 else {
12997 return;
12998 };
12999 let pool = test_pool();
13000 let first = test_market_sell_order(pool.instrument_id);
13001 let second = OrderTestBuilder::new(OrderType::Market)
13002 .trader_id(TraderId::from("TRADER-001"))
13003 .strategy_id(StrategyId::from("S-001"))
13004 .instrument_id(pool.instrument_id)
13005 .client_order_id(ClientOrderId::from("O-SWAP-002"))
13006 .side(OrderSide::Sell)
13007 .quantity(Quantity::from("0.001"))
13008 .build();
13009 cache
13010 .borrow_mut()
13011 .add_order(second.clone(), None, None, false)
13012 .unwrap();
13013 let mut receiver = start_with_events(&mut client);
13014
13015 client.submit_order(submit_order_cmd(&first)).unwrap();
13016 await_recorded_requests(&state, "eth_sendRawTransaction", 1).await;
13017 client.submit_order(submit_order_cmd(&second)).unwrap();
13018 let event = tokio::time::timeout(TEST_TIMEOUT, receiver.recv())
13019 .await
13020 .unwrap()
13021 .unwrap();
13022 let ExecutionEvent::Order(OrderEventAny::Denied(denied)) = event else {
13023 panic!("expected OrderDenied, was {event:?}");
13024 };
13025 assert_eq!(denied.client_order_id, second.client_order_id());
13026 assert!(
13027 denied
13028 .reason
13029 .contains("at most one transaction can be in flight"),
13030 "was: {}",
13031 denied.reason
13032 );
13033 broadcast_release.add_permits(1);
13034 await_pending_tasks(&client).await;
13035
13036 let events = collect_order_events(&mut receiver);
13037 assert_swap_submitted_and_filled(&events);
13038
13039 let broadcasts = state
13040 .recorded_requests()
13041 .into_iter()
13042 .filter(|request| request["method"] == "eth_sendRawTransaction")
13043 .count();
13044 assert_eq!(broadcasts, 1);
13045 let row_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
13046 "SELECT COUNT(*) FROM {schema}.execution_intent"
13047 )))
13048 .fetch_one(&admin_pool)
13049 .await
13050 .unwrap();
13051 assert_eq!(row_count, 1);
13052 assert!(client.in_flight.lock().is_none());
13053
13054 drop_execution_schema(&admin_pool, &schema).await;
13055 }
13056
13057 #[tokio::test]
13058 async fn preflight_ready_when_all_checks_pass() {
13059 let (client, state) = client_with_mock_rpc(ready_rpc_state()).await;
13060 let pool = test_pool();
13061
13062 let report = client.preflight(&pool.instrument_id).await.unwrap();
13063
13064 assert!(report.ready, "issues: {:?}", report.issues);
13065 assert!(report.issues.is_empty());
13066 assert_eq!(report.expected_chain_id, 42161);
13067 assert_eq!(report.actual_chain_id, 42161);
13068 assert!(report.chain_id_matches);
13069 assert_eq!(
13070 report.pool.address,
13071 address!("C6962004f452bE9203591991D15f6b388e09E8D0")
13072 );
13073 assert!(report.pool.has_deployed_code);
13074 assert_eq!(report.pool.fee, Some(500));
13075 assert_eq!(
13076 report.pool.base_token,
13077 address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1")
13078 );
13079 assert_eq!(
13080 report.pool.quote_token,
13081 address!("af88d065e77c8cC2239327C5EDb3A432268e5831")
13082 );
13083 assert_eq!(report.routers.len(), 1);
13084 assert!(report.routers[0].has_deployed_code);
13085 assert_eq!(report.tokens.len(), 2);
13086 assert_eq!(
13087 report.tokens[0].wallet_balance,
13088 U256::from(500_000_000_000_000_000u64)
13089 );
13090 assert_eq!(
13091 report.tokens[0].router_allowances,
13092 vec![(
13093 address!("E592427A0AEce92De3Edee1F18E0157C05861564"),
13094 U256::from(1_000_000_000_000_000_000u64)
13095 )]
13096 );
13097 assert_eq!(
13098 report.native_balance_wei,
13099 U256::from(1_000_000_000_000_000_000u64)
13100 );
13101 assert_eq!(report.base_fee_per_gas_wei, 100_000_000);
13102 assert_eq!(report.max_priority_fee_per_gas_wei, 10_000_000);
13103 assert_eq!(report.derived_max_fee_per_gas_wei, 130_000_000);
13104 assert!(report.fee_within_ceiling);
13105
13106 let requests = state.recorded_requests();
13107 for method in ["eth_getCode", "eth_call", "eth_getBalance"] {
13108 let matching: Vec<_> = requests
13109 .iter()
13110 .filter(|request| request["method"] == method)
13111 .collect();
13112 assert!(!matching.is_empty(), "method {method}");
13113 assert!(
13114 matching
13115 .iter()
13116 .all(|request| request["params"][1] == "latest"),
13117 "method {method}: {matching:?}"
13118 );
13119 }
13120 }
13121
13122 #[tokio::test]
13123 async fn preflight_not_ready_without_pool_fee() {
13124 let addr = start_mock_rpc_server(ready_rpc_state()).await;
13125 let mut pool = test_pool();
13126 pool.fee = None;
13127 let client = test_client_from_config(test_config(format!("http://{addr}")), pool.clone());
13128
13129 let report = client.preflight(&pool.instrument_id).await.unwrap();
13130
13131 assert!(!report.ready);
13132 assert_eq!(report.pool.fee, None);
13133 assert_eq!(report.issues, vec!["Pool fee tier is missing"]);
13134 }
13135
13136 #[tokio::test]
13137 async fn preflight_not_ready_on_wrong_chain() {
13138 let state = ready_rpc_state().with_response("eth_chainId", CHAIN_ID_ETHEREUM);
13139 let (client, _) = client_with_mock_rpc(state).await;
13140 let pool = test_pool();
13141
13142 let report = client.preflight(&pool.instrument_id).await.unwrap();
13143
13144 assert!(!report.ready);
13145 assert!(!report.chain_id_matches);
13146 assert!(
13147 report
13148 .issues
13149 .iter()
13150 .any(|issue| issue.contains("Chain ID mismatch"))
13151 );
13152 }
13153
13154 #[tokio::test]
13155 async fn preflight_not_ready_without_deployed_code() {
13156 let state = ready_rpc_state().with_response("eth_getCode", GET_CODE_EMPTY);
13157 let (client, _) = client_with_mock_rpc(state).await;
13158 let pool = test_pool();
13159
13160 let report = client.preflight(&pool.instrument_id).await.unwrap();
13161
13162 assert!(!report.ready);
13163 assert!(!report.pool.has_deployed_code);
13164 assert!(!report.routers[0].has_deployed_code);
13165 assert!(report.tokens.iter().all(|t| !t.has_deployed_code));
13166 assert!(
13167 report
13168 .issues
13169 .iter()
13170 .any(|issue| issue.contains("No deployed bytecode at router address"))
13171 );
13172 }
13173
13174 #[tokio::test]
13175 async fn preflight_not_ready_with_zero_balances_and_allowance() {
13176 let state = ready_rpc_state()
13177 .with_response("eth_getBalance", GET_BALANCE_ZERO)
13178 .with_call_response(BALANCE_OF_SELECTOR, CALL_ZERO)
13179 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO);
13180 let (client, _) = client_with_mock_rpc(state).await;
13181 let pool = test_pool();
13182
13183 let report = client.preflight(&pool.instrument_id).await.unwrap();
13184
13185 assert!(!report.ready);
13186 assert!(
13187 report
13188 .issues
13189 .iter()
13190 .any(|issue| issue.contains("Native currency balance is zero"))
13191 );
13192 assert!(
13193 report
13194 .issues
13195 .iter()
13196 .any(|issue| issue.contains("balance is zero"))
13197 );
13198 assert!(
13199 report
13200 .issues
13201 .iter()
13202 .any(|issue| issue.contains("No router allowance"))
13203 );
13204 }
13205
13206 #[tokio::test]
13207 async fn preflight_not_ready_when_fees_exceed_ceiling() {
13208 let addr = start_mock_rpc_server(ready_rpc_state()).await;
13209 let mut config = test_config(format!("http://{addr}"));
13210 config.max_fee_per_gas_wei = 1;
13211 let pool = test_pool();
13212 let client = test_client_from_config(config, pool.clone());
13213
13214 let report = client.preflight(&pool.instrument_id).await.unwrap();
13215
13216 assert!(!report.ready);
13217 assert!(!report.fee_within_ceiling);
13218 assert_eq!(report.derived_max_fee_per_gas_wei, 130_000_000);
13219 assert!(
13220 report
13221 .issues
13222 .iter()
13223 .any(|issue| issue.contains("exceeds ceiling"))
13224 );
13225 }
13226
13227 #[rstest]
13228 fn resolve_pool_rejects_unknown_pool() {
13229 let client = test_client("http://127.0.0.1:1".to_string());
13230 let unknown: InstrumentId = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45.Arbitrum:UniswapV3"
13231 .parse()
13232 .unwrap();
13233
13234 let error = client.resolve_pool(&unknown).unwrap_err();
13235
13236 assert!(error.to_string().contains("Unknown pool"), "was: {error}");
13237 }
13238
13239 #[rstest]
13240 fn resolve_pool_rejects_mismatched_chain() {
13241 let client = test_client("http://127.0.0.1:1".to_string());
13242 let ethereum_pool: InstrumentId =
13243 "0xC6962004f452bE9203591991D15f6b388e09E8D0.Ethereum:UniswapV3"
13244 .parse()
13245 .unwrap();
13246
13247 let error = client.resolve_pool(ðereum_pool).unwrap_err();
13248
13249 assert!(
13250 error
13251 .to_string()
13252 .contains("does not match the client chain"),
13253 "was: {error}"
13254 );
13255 }
13256
13257 #[rstest]
13258 fn resolve_pool_rejects_unsupported_dex() {
13259 let client = test_client("http://127.0.0.1:1".to_string());
13260 let v4_pool: InstrumentId = "0xC6962004f452bE9203591991D15f6b388e09E8D0.Arbitrum:UniswapV4"
13261 .parse()
13262 .unwrap();
13263
13264 let error = client.resolve_pool(&v4_pool).unwrap_err();
13265
13266 assert!(
13267 error.to_string().contains("only UniswapV3 is supported"),
13268 "was: {error}"
13269 );
13270 }
13271
13272 #[rstest]
13273 fn resolve_pool_rejects_pool_id_identifier() {
13274 let client = test_client("http://127.0.0.1:1".to_string());
13275 let pool_id: InstrumentId =
13276 "0x0000000000000000000000000000000000000000000000000000000000000000.Arbitrum:UniswapV3"
13277 .parse()
13278 .unwrap();
13279
13280 let error = client.resolve_pool(&pool_id).unwrap_err();
13281
13282 assert!(
13283 error
13284 .to_string()
13285 .contains("only address identifiers are supported"),
13286 "was: {error}"
13287 );
13288 }
13289
13290 #[rstest]
13291 fn resolve_pool_rejects_ambiguous_token_priority() {
13292 let chain = Arc::new(chains::ARBITRUM.clone());
13293 let dex = UNISWAP_V3.dex.clone();
13294 let token_a = Token::new(
13295 chain.clone(),
13296 address!("1111111111111111111111111111111111111111"),
13297 "Token A".to_string(),
13298 "TOKA".to_string(),
13299 18,
13300 );
13301 let token_b = Token::new(
13302 chain.clone(),
13303 address!("2222222222222222222222222222222222222222"),
13304 "Token B".to_string(),
13305 "TOKB".to_string(),
13306 18,
13307 );
13308 let pool = Pool::new(
13309 chain,
13310 dex,
13311 address!("3333333333333333333333333333333333333333"),
13312 PoolIdentifier::from_address(address!("3333333333333333333333333333333333333333")),
13313 55_000_000,
13314 token_a,
13315 token_b,
13316 Some(500),
13317 Some(10),
13318 UnixNanos::default(),
13319 );
13320 let client =
13321 test_client_from_config(test_config("http://127.0.0.1:1".to_string()), pool.clone());
13322
13323 let error = client.resolve_pool(&pool.instrument_id).unwrap_err();
13324
13325 assert!(error.to_string().contains("ambiguous"), "was: {error}");
13326 }
13327
13328 #[rstest]
13329 fn new_parses_pair_specific_quote_spend_limits_with_distinct_precisions() {
13330 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13331 config.quote_spend_limits = Some(vec![
13332 quote_spend_limit(WETH, USDC, 18, &U256::MAX.to_string()),
13333 quote_spend_limit(USDC, WETH, 6, "1000000000"),
13334 ]);
13335
13336 let client = test_client_from_config(config, test_pool());
13337 let sell_ceiling = client
13338 .transaction_limits
13339 .quote_spend_limits
13340 .get(&(WETH_ADDRESS, USDC_ADDRESS))
13341 .unwrap();
13342 let buy_ceiling = client
13343 .transaction_limits
13344 .quote_spend_limits
13345 .get(&(USDC_ADDRESS, WETH_ADDRESS))
13346 .unwrap();
13347
13348 assert_eq!(sell_ceiling.spend_token, WETH_ADDRESS);
13349 assert_eq!(sell_ceiling.spend_token_decimals, 18);
13350 assert_eq!(sell_ceiling.max_amount, U256::MAX);
13351 assert_eq!(buy_ceiling.spend_token, USDC_ADDRESS);
13352 assert_eq!(buy_ceiling.spend_token_decimals, 6);
13353 assert_eq!(buy_ceiling.max_amount, U256::from(1_000_000_000u64));
13354 }
13355
13356 #[rstest]
13357 fn new_rejects_quote_spend_limit_token_pair_mismatch() {
13358 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13359 config.quote_spend_limits.as_mut().unwrap()[0].spend_token = WETH.to_string();
13360
13361 let error = test_client_result(config, test_pool()).unwrap_err();
13362
13363 assert!(
13364 error
13365 .to_string()
13366 .contains("`spend_token` must match `token_in`"),
13367 "was: {error}"
13368 );
13369 }
13370
13371 #[rstest]
13372 fn new_rejects_quote_spend_limit_pair_outside_allowlist() {
13373 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13374 config.quote_spend_limits = Some(vec![quote_spend_limit(
13375 USDC,
13376 "0x1111111111111111111111111111111111111111",
13377 6,
13378 "1000000000",
13379 )]);
13380
13381 let error = test_client_result(config, test_pool()).unwrap_err();
13382
13383 assert!(
13384 error
13385 .to_string()
13386 .contains("is not in the `allowed_token_pairs` allowlist"),
13387 "was: {error}"
13388 );
13389 }
13390
13391 #[rstest]
13392 fn new_rejects_duplicate_quote_spend_pairs() {
13393 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13394 config.quote_spend_limits = Some(vec![
13395 quote_spend_limit(USDC, WETH, 6, "1000000000"),
13396 quote_spend_limit(USDC, WETH, 6, "2000000000"),
13397 ]);
13398
13399 let error = test_client_result(config, test_pool()).unwrap_err();
13400
13401 assert!(
13402 error
13403 .to_string()
13404 .contains("Duplicate quote spend limit for token pair"),
13405 "was: {error}"
13406 );
13407 }
13408
13409 #[rstest]
13410 #[case::empty("")]
13411 #[case::signed("-1")]
13412 #[case::fractional("1.5")]
13413 #[case::hexadecimal("0x10")]
13414 #[case::overflow(
13415 "115792089237316195423570985008687907853269984665640564039457584007913129639936"
13416 )]
13417 fn new_rejects_invalid_quote_spend_max_amount(#[case] max_amount: &str) {
13418 let mut config = buy_test_config("http://127.0.0.1:1".to_string());
13419 config.quote_spend_limits.as_mut().unwrap()[0].max_amount = max_amount.to_string();
13420
13421 let error = test_client_result(config, test_pool()).unwrap_err();
13422
13423 assert!(
13424 error.to_string().contains("Quote spend limit `max_amount`"),
13425 "was: {error}"
13426 );
13427 }
13428
13429 #[rstest]
13430 #[case::allowed_token_pairs("allowed_token_pairs")]
13431 #[case::slippage_bps("slippage_bps")]
13432 #[case::max_slippage_bps("max_slippage_bps")]
13433 #[case::max_order_amount("max_order_amount")]
13434 #[case::deadline_seconds("deadline_seconds")]
13435 #[case::max_quote_age_blocks("max_quote_age_blocks")]
13436 #[case::receipt_timeout_secs("receipt_timeout_secs")]
13437 fn new_rejects_each_missing_transaction_limit(#[case] missing: &str) {
13438 let mut config = test_config("http://127.0.0.1:1".to_string());
13439 match missing {
13440 "allowed_token_pairs" => config.allowed_token_pairs = None,
13441 "slippage_bps" => config.slippage_bps = None,
13442 "max_slippage_bps" => config.max_slippage_bps = None,
13443 "max_order_amount" => config.max_order_amount = None,
13444 "deadline_seconds" => config.deadline_seconds = None,
13445 "max_quote_age_blocks" => config.max_quote_age_blocks = None,
13446 "receipt_timeout_secs" => config.receipt_timeout_secs = None,
13447 _ => unreachable!(),
13448 }
13449
13450 let error = test_client_result(config, test_pool()).unwrap_err();
13451
13452 assert_eq!(
13453 error.to_string(),
13454 "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"
13455 );
13456 }
13457
13458 #[rstest]
13459 fn new_rejects_empty_router_allowlist() {
13460 let mut config = test_config("http://127.0.0.1:1".to_string());
13461 config.router_addresses = Vec::new();
13462
13463 let error = test_client_result(config, test_pool()).unwrap_err();
13464
13465 assert!(
13466 error.to_string().contains("at least one router address"),
13467 "was: {error}"
13468 );
13469 }
13470
13471 #[tokio::test]
13472 async fn wrap_refuses_without_durable_store() {
13473 let (mut client, _) = client_with_mock_rpc(ready_rpc_state()).await;
13474 client.core.set_connected();
13475
13476 let error = client.wrap(U256::from(1_000u64)).await.unwrap_err();
13477
13478 assert!(
13479 error.to_string().contains("No durable store configured"),
13480 "was: {error}"
13481 );
13482 }
13483
13484 #[tokio::test]
13485 async fn approve_rejects_router_outside_allowlist() {
13486 let (mut client, state) = client_with_mock_rpc(ready_rpc_state()).await;
13487
13488 let error = client
13489 .approve(
13490 WETH_ADDRESS,
13491 U256::from(1_000u64),
13492 address!("68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"),
13493 )
13494 .await
13495 .err()
13496 .unwrap();
13497
13498 assert!(
13499 error
13500 .to_string()
13501 .contains("not in the configured `router_addresses` allowlist"),
13502 "was: {error}"
13503 );
13504 assert!(state.recorded_requests().is_empty());
13505 }
13506
13507 #[tokio::test]
13508 async fn approve_rejects_token_outside_input_allowlist() {
13509 let (mut client, state) = client_with_mock_rpc(ready_rpc_state()).await;
13510
13511 let error = client
13512 .approve(USDC_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
13513 .await
13514 .unwrap_err();
13515
13516 assert!(
13517 error
13518 .to_string()
13519 .contains("is not an input token in the configured `allowed_token_pairs`"),
13520 "was: {error}"
13521 );
13522 assert!(state.recorded_requests().is_empty());
13523 }
13524
13525 #[tokio::test]
13526 async fn in_flight_guard_rejects_second_transaction() {
13527 let (mut client, _) = client_with_mock_rpc(ready_rpc_state()).await;
13528 client.core.set_connected();
13529 *client.in_flight.lock() = Some(InFlightSlot::AwaitingFinality(InFlightTransaction {
13530 intent_id: 1,
13531 nonce: 7,
13532 tx_hash: B256::ZERO,
13533 purpose: TransactionPurpose::Wrap,
13534 }));
13535
13536 let error = client.wrap(U256::from(1_000u64)).await.unwrap_err();
13537
13538 assert!(
13539 error.to_string().contains("still awaiting finality"),
13540 "was: {error}"
13541 );
13542 }
13543
13544 #[tokio::test]
13545 async fn wrap_rejects_zero_amount() {
13546 let (mut client, _) = client_with_mock_rpc(ready_rpc_state()).await;
13547
13548 let error = client.wrap(U256::ZERO).await.unwrap_err();
13549
13550 assert!(
13551 error.to_string().contains("Wrap amount must be positive"),
13552 "was: {error}"
13553 );
13554 }
13555
13556 #[tokio::test]
13557 async fn wrap_rejects_code_free_target_before_broadcast() {
13558 let state = execution_rpc_state().with_response("eth_getCode", GET_CODE_EMPTY);
13559 let Some((admin_pool, schema, mut client, state)) =
13560 execution_client_with_database("execution_wrap_code_free_test", state).await
13561 else {
13562 return;
13563 };
13564
13565 let error = client
13566 .wrap(U256::from(1_000_000_000_000_000u64))
13567 .await
13568 .unwrap_err();
13569
13570 assert!(
13571 error
13572 .to_string()
13573 .contains("pre-sign deployment manifest verification disagreed"),
13574 "was: {error}"
13575 );
13576 assert!(client.in_flight.lock().is_none());
13577 let requests = state.recorded_requests();
13578 assert_eq!(
13579 requests
13580 .iter()
13581 .filter(|request| request["method"] == "eth_getCode")
13582 .count(),
13583 3
13584 );
13585 assert!(
13586 requests
13587 .iter()
13588 .all(|request| request["method"] != "eth_sendRawTransaction")
13589 );
13590
13591 drop_execution_schema(&admin_pool, &schema).await;
13592 }
13593
13594 #[tokio::test]
13595 async fn wrap_rejects_unrelated_target_before_broadcast() {
13596 let state = execution_rpc_state().with_response("eth_call", CALL_EMPTY);
13597 let Some((admin_pool, schema, mut client, state)) =
13598 execution_client_with_database("execution_wrap_unrelated_test", state).await
13599 else {
13600 return;
13601 };
13602
13603 let error = client
13604 .wrap(U256::from(1_000_000_000_000_000u64))
13605 .await
13606 .unwrap_err();
13607
13608 assert!(
13609 error
13610 .to_string()
13611 .contains("pre-sign wrapped token probe verification is unavailable"),
13612 "was: {error}"
13613 );
13614 assert!(client.in_flight.lock().is_none());
13615 let requests = state.recorded_requests();
13616 assert!(
13617 requests
13618 .iter()
13619 .all(|request| request["method"] != "eth_sendRawTransaction")
13620 );
13621
13622 drop_execution_schema(&admin_pool, &schema).await;
13623 }
13624
13625 #[tokio::test]
13626 async fn wrap_rejects_included_transaction_without_balance_delta() {
13627 let state = broadcast_rpc_state().with_response_sequence("eth_call", &[CALL_BALANCE; 9]);
13628 let Some((admin_pool, schema, mut client, state)) =
13629 execution_client_with_database("execution_wrap_no_delta_test", state).await
13630 else {
13631 return;
13632 };
13633
13634 let error = client
13635 .wrap(U256::from(1_000_000_000_000_000u64))
13636 .await
13637 .unwrap_err();
13638
13639 assert!(
13640 error.to_string().contains("did not increase"),
13641 "was: {error}"
13642 );
13643 let in_flight = awaiting_in_flight(&client);
13644 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
13645 assert_eq!(
13646 execution_intent_markers(&admin_pool, &schema).await,
13647 vec![("wrap".into(), "broadcast".into(), false, true)]
13648 );
13649 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
13650 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
13651 )))
13652 .fetch_one(&admin_pool)
13653 .await
13654 .unwrap();
13655 assert_eq!(nonce_state, (7, 0));
13656 let broadcasts = state
13657 .recorded_requests()
13658 .into_iter()
13659 .filter(|request| request["method"] == "eth_sendRawTransaction")
13660 .count();
13661 assert_eq!(broadcasts, 1);
13662
13663 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000u64)).await;
13664 let block = finalized_wrap_block(expected_hash);
13665 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
13666 let restart_state = with_finalized_identity(
13667 execution_rpc_state()
13668 .with_response("eth_getTransactionReceipt", &receipt)
13669 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
13670 .with_response_sequence("eth_call", &[CALL_BALANCE; 6]),
13671 &block,
13672 &receipt,
13673 );
13674 let addr = start_mock_rpc_server(restart_state).await;
13675 let error = later_reconnect(client, format!("http://{addr}")).await;
13676 assert!(
13677 error.to_string().contains("did not increase"),
13678 "was: {error}"
13679 );
13680 assert_eq!(
13681 execution_intent_markers(&admin_pool, &schema).await,
13682 vec![("wrap".into(), "broadcast".into(), false, true)]
13683 );
13684 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
13685 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
13686 )))
13687 .fetch_one(&admin_pool)
13688 .await
13689 .unwrap();
13690 assert_eq!(nonce_state, (7, 0));
13691
13692 drop_execution_schema(&admin_pool, &schema).await;
13693 }
13694
13695 #[tokio::test]
13696 async fn wrap_reports_inclusion_when_postcondition_read_fails() {
13697 let state = broadcast_rpc_state().with_response_sequence(
13698 "eth_call",
13699 &[
13700 CALL_BALANCE,
13701 CALL_BALANCE,
13702 CALL_BALANCE,
13703 CALL_BALANCE,
13704 CALL_BALANCE,
13705 CALL_BALANCE,
13706 RPC_METHOD_NOT_FOUND,
13707 RPC_METHOD_NOT_FOUND,
13708 RPC_METHOD_NOT_FOUND,
13709 ],
13710 );
13711 let Some((admin_pool, schema, mut client, _)) =
13712 execution_client_with_database("execution_wrap_postcondition_rpc_test", state).await
13713 else {
13714 return;
13715 };
13716
13717 let error = client
13718 .wrap(U256::from(1_000_000_000_000_000u64))
13719 .await
13720 .unwrap_err();
13721
13722 let message = error.to_string();
13723 assert!(
13724 message.contains("failed to verify WETH balance after included transaction 0x"),
13725 "was: {message}"
13726 );
13727 assert!(message.contains("at block 30346561"), "was: {message}");
13728 let in_flight = awaiting_in_flight(&client);
13729 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
13730 assert_eq!(
13731 execution_intent_markers(&admin_pool, &schema).await,
13732 vec![("wrap".into(), "broadcast".into(), false, true)]
13733 );
13734
13735 drop_execution_schema(&admin_pool, &schema).await;
13736 }
13737
13738 #[tokio::test]
13739 async fn approve_rejects_false_return_before_broadcast() {
13740 let state = execution_rpc_state().with_response("eth_call", CALL_ZERO);
13741 let Some((admin_pool, schema, mut client, state)) =
13742 execution_client_with_database("execution_approve_false_test", state).await
13743 else {
13744 return;
13745 };
13746
13747 let error = client
13748 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
13749 .await
13750 .unwrap_err();
13751
13752 assert!(error.to_string().contains("returned false"), "was: {error}");
13753 assert!(client.in_flight.lock().is_none());
13754 let requests = state.recorded_requests();
13755 let approval_calls = requests
13756 .iter()
13757 .filter(|request| {
13758 request["method"] == "eth_call"
13759 && request["params"][0]["data"]
13760 .as_str()
13761 .is_some_and(|data| data.starts_with("0x095ea7b3"))
13762 })
13763 .collect::<Vec<_>>();
13764 assert_eq!(approval_calls.len(), 3);
13765 for request in approval_calls {
13766 assert_eq!(
13767 request["params"][0]["from"]
13768 .as_str()
13769 .unwrap()
13770 .parse::<Address>()
13771 .unwrap(),
13772 WALLET.parse::<Address>().unwrap()
13773 );
13774 assert_eq!(request["params"][1], FIXTURE_BLOCK_PARAM);
13775 }
13776 assert!(
13777 requests
13778 .iter()
13779 .all(|request| request["method"] != "eth_getTransactionCount")
13780 );
13781 assert!(
13782 requests
13783 .iter()
13784 .all(|request| request["method"] != "eth_sendRawTransaction")
13785 );
13786
13787 drop_execution_schema(&admin_pool, &schema).await;
13788 }
13789
13790 #[tokio::test]
13791 async fn approve_rejects_router_with_wrong_factory_before_signing() {
13792 let state = ready_rpc_state().with_call_response(FACTORY_SELECTOR, CALL_ZERO);
13793 let Some((admin_pool, schema, mut client, state)) =
13794 execution_client_with_database("execution_approve_factory_test", state).await
13795 else {
13796 return;
13797 };
13798
13799 let error = client
13800 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
13801 .await
13802 .unwrap_err();
13803
13804 assert!(
13805 error
13806 .to_string()
13807 .contains("pre-sign deployment manifest verification disagreed"),
13808 "was: {error}"
13809 );
13810 let requests = state.recorded_requests();
13811 assert!(
13812 requests
13813 .iter()
13814 .all(|request| request["method"] != "eth_getTransactionCount")
13815 );
13816 assert!(
13817 requests
13818 .iter()
13819 .all(|request| request["method"] != "eth_sendRawTransaction")
13820 );
13821
13822 drop_execution_schema(&admin_pool, &schema).await;
13823 }
13824
13825 #[tokio::test]
13826 async fn approve_rejects_router_with_wrong_weth_before_signing() {
13827 let state = ready_rpc_state().with_call_response(WETH9_SELECTOR, CALL_ZERO);
13828 let Some((admin_pool, schema, mut client, state)) =
13829 execution_client_with_database("execution_approve_weth_test", state).await
13830 else {
13831 return;
13832 };
13833
13834 let error = client
13835 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
13836 .await
13837 .unwrap_err();
13838
13839 assert!(
13840 error
13841 .to_string()
13842 .contains("pre-sign deployment manifest verification disagreed"),
13843 "was: {error}"
13844 );
13845 let requests = state.recorded_requests();
13846 assert!(
13847 requests
13848 .iter()
13849 .all(|request| request["method"] != "eth_getTransactionCount")
13850 );
13851 assert!(
13852 requests
13853 .iter()
13854 .all(|request| request["method"] != "eth_sendRawTransaction")
13855 );
13856
13857 drop_execution_schema(&admin_pool, &schema).await;
13858 }
13859
13860 #[tokio::test]
13861 async fn approve_rejects_nonzero_to_nonzero_transition_before_signing() {
13862 let state = ready_rpc_state();
13863 let Some((admin_pool, schema, mut client, state)) =
13864 execution_client_with_database("execution_approve_nonzero_test", state).await
13865 else {
13866 return;
13867 };
13868
13869 let error = client
13870 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
13871 .await
13872 .unwrap_err();
13873
13874 assert!(
13875 error.to_string().contains("approve zero before setting"),
13876 "was: {error}"
13877 );
13878 let requests = state.recorded_requests();
13879 assert!(
13880 requests
13881 .iter()
13882 .all(|request| request["method"] != "eth_getTransactionCount")
13883 );
13884 assert!(
13885 requests
13886 .iter()
13887 .all(|request| request["method"] != "eth_sendRawTransaction")
13888 );
13889
13890 drop_execution_schema(&admin_pool, &schema).await;
13891 }
13892
13893 #[tokio::test]
13894 async fn approve_zero_revokes_under_unlimited_policy() {
13895 let state = broadcast_rpc_state()
13896 .with_response("eth_call", CALL_BOOL_TRUE)
13897 .with_call_response_sequence(
13898 ALLOWANCE_SELECTOR,
13899 &[
13900 CALL_ALLOWANCE,
13901 CALL_ALLOWANCE,
13902 CALL_ALLOWANCE,
13903 CALL_ZERO,
13904 CALL_ZERO,
13905 CALL_ZERO,
13906 ],
13907 );
13908 let Some((admin_pool, schema, mut client, state)) =
13909 execution_client_with_database("execution_approve_revoke_test", state).await
13910 else {
13911 return;
13912 };
13913 client.config.unlimited_approval = true;
13914
13915 let tx_hash = client
13916 .approve(WETH_ADDRESS, U256::ZERO, ROUTER_ADDRESS)
13917 .await
13918 .unwrap();
13919
13920 assert_eq!(tx_hash, expected_approve_tx_hash(U256::ZERO).await);
13921 let approve_data = state
13922 .recorded_requests()
13923 .into_iter()
13924 .find_map(|request| {
13925 (request["method"] == "eth_estimateGas")
13926 .then(|| request["params"][0]["data"].as_str().map(str::to_owned))
13927 .flatten()
13928 })
13929 .unwrap();
13930 assert!(approve_data.starts_with("0x095ea7b3"));
13931 assert!(approve_data.ends_with(&"0".repeat(64)));
13932 assert_eq!(
13933 state
13934 .recorded_requests()
13935 .iter()
13936 .filter(|request| {
13937 request["method"] == "eth_call"
13938 && request["params"][0]["data"]
13939 .as_str()
13940 .is_some_and(|data| data.starts_with(FACTORY_SELECTOR))
13941 })
13942 .count(),
13943 12
13944 );
13945 assert_eq!(
13946 execution_intent_markers(&admin_pool, &schema).await,
13947 vec![("approve".into(), "finalized".into(), true, false)]
13948 );
13949
13950 drop_execution_schema(&admin_pool, &schema).await;
13951 }
13952
13953 #[tokio::test]
13954 async fn approve_accepts_empty_return_with_sufficient_allowance() {
13955 let state = broadcast_rpc_state()
13956 .with_response("eth_call", CALL_EMPTY)
13957 .with_call_response_sequence(
13958 ALLOWANCE_SELECTOR,
13959 &[
13960 CALL_ZERO,
13961 CALL_ZERO,
13962 CALL_ZERO,
13963 CALL_ALLOWANCE_1000,
13964 CALL_ALLOWANCE_1000,
13965 CALL_ALLOWANCE_1000,
13966 ],
13967 );
13968 let Some((admin_pool, schema, mut client, _)) =
13969 execution_client_with_database("execution_approve_empty_test", state).await
13970 else {
13971 return;
13972 };
13973
13974 let tx_hash = client
13975 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
13976 .await
13977 .unwrap();
13978
13979 let record = client
13980 .cache
13981 .get_execution_transaction(42161, &tx_hash.to_string())
13982 .await
13983 .unwrap()
13984 .unwrap();
13985 assert_eq!(record.purpose, "approve");
13986 assert_eq!(record.status, "finalized");
13987 assert!(client.in_flight.lock().is_none());
13988 assert_eq!(
13989 execution_intent_markers(&admin_pool, &schema).await,
13990 vec![("approve".into(), "finalized".into(), true, false)]
13991 );
13992
13993 drop_execution_schema(&admin_pool, &schema).await;
13994 }
13995
13996 #[tokio::test]
13997 async fn approve_rejects_empty_return_with_insufficient_allowance() {
13998 let state = broadcast_rpc_state()
13999 .with_response("eth_call", CALL_EMPTY)
14000 .with_call_response_sequence(ALLOWANCE_SELECTOR, &[CALL_ZERO; 6]);
14001 let Some((admin_pool, schema, mut client, _)) =
14002 execution_client_with_database("execution_approve_insufficient_test", state).await
14003 else {
14004 return;
14005 };
14006
14007 let error = client
14008 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
14009 .await
14010 .unwrap_err();
14011
14012 assert!(
14013 error
14014 .to_string()
14015 .contains("does not equal the requested amount"),
14016 "was: {error}"
14017 );
14018 let in_flight = awaiting_in_flight(&client);
14019 assert_eq!(in_flight.purpose, TransactionPurpose::Approve);
14020 assert_eq!(
14021 execution_intent_markers(&admin_pool, &schema).await,
14022 vec![("approve".into(), "broadcast".into(), false, true)]
14023 );
14024 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
14025 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
14026 )))
14027 .fetch_one(&admin_pool)
14028 .await
14029 .unwrap();
14030 assert_eq!(nonce_state, (7, 0));
14031
14032 let expected_hash = expected_approve_tx_hash(U256::from(1_000u64)).await;
14033 let block = finalized_approve_block(expected_hash, U256::from(1_000u64));
14034 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
14035 let restart_state = with_finalized_identity(
14036 execution_rpc_state()
14037 .with_response("eth_getTransactionReceipt", &receipt)
14038 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
14039 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO),
14040 &block,
14041 &receipt,
14042 );
14043 let addr = start_mock_rpc_server(restart_state).await;
14044 let error = later_reconnect(client, format!("http://{addr}")).await;
14045 assert!(
14046 error
14047 .to_string()
14048 .contains("does not equal the requested amount"),
14049 "was: {error}"
14050 );
14051 assert_eq!(
14052 execution_intent_markers(&admin_pool, &schema).await,
14053 vec![("approve".into(), "broadcast".into(), false, true)]
14054 );
14055 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
14056 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
14057 )))
14058 .fetch_one(&admin_pool)
14059 .await
14060 .unwrap();
14061 assert_eq!(nonce_state, (7, 0));
14062
14063 drop_execution_schema(&admin_pool, &schema).await;
14064 }
14065
14066 #[tokio::test]
14067 async fn approve_reports_inclusion_when_postcondition_read_fails() {
14068 let state = broadcast_rpc_state()
14069 .with_response("eth_call", CALL_BOOL_TRUE)
14070 .with_call_response_sequence(
14071 ALLOWANCE_SELECTOR,
14072 &[
14073 CALL_ZERO,
14074 CALL_ZERO,
14075 CALL_ZERO,
14076 RPC_METHOD_NOT_FOUND,
14077 RPC_METHOD_NOT_FOUND,
14078 RPC_METHOD_NOT_FOUND,
14079 ],
14080 );
14081 let Some((admin_pool, schema, mut client, _)) =
14082 execution_client_with_database("execution_approve_postcondition_rpc_test", state).await
14083 else {
14084 return;
14085 };
14086
14087 let error = client
14088 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
14089 .await
14090 .unwrap_err();
14091
14092 let message = error.to_string();
14093 assert!(
14094 message.contains("failed to verify router allowance after included transaction 0x"),
14095 "was: {message}"
14096 );
14097 assert!(message.contains("at block 30346561"), "was: {message}");
14098 let in_flight = awaiting_in_flight(&client);
14099 assert_eq!(in_flight.purpose, TransactionPurpose::Approve);
14100 assert_eq!(
14101 execution_intent_markers(&admin_pool, &schema).await,
14102 vec![("approve".into(), "broadcast".into(), false, true)]
14103 );
14104
14105 drop_execution_schema(&admin_pool, &schema).await;
14106 }
14107
14108 #[tokio::test]
14109 async fn wallet_balance_refresh_replaces_complete_snapshot_with_exact_precision() {
14110 let state = execution_rpc_state()
14111 .with_response_sequence("eth_getBalance", &[GET_BALANCE, GET_BALANCE_ZERO])
14112 .with_response_sequence(
14113 "eth_call",
14114 &[
14115 CALL_BALANCE_WETH,
14116 CALL_BALANCE_USDC,
14117 CALL_BALANCE_WETH_UPDATED,
14118 CALL_BALANCE_USDC_UPDATED,
14119 ],
14120 );
14121 let (mut client, _, _) =
14122 client_with_token_mock_rpc(state, "BLOCKCHAIN_TEST_BALANCE_REPLACE").await;
14123 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14124 replace_exec_event_sender(sender);
14125 client.start().unwrap();
14126
14127 client.refresh_wallet_balances().await.unwrap();
14128 let balances = client.wallet_balance.lock().as_account_balances().unwrap();
14129
14130 assert_eq!(balances.len(), 3);
14131 assert_eq!(balances[0].currency.code, "ETH");
14132 assert_eq!(balances[0].currency.name, "Ethereum");
14133 assert_eq!(balances[0].currency.precision, 18);
14134 assert_eq!(balances[0].total.raw(), 1_000_000_000_000_000_000);
14135 assert_eq!(balances[0].free, balances[0].total);
14136 assert_eq!(balances[0].locked, Money::zero(balances[0].currency));
14137 assert_eq!(balances[1].currency.code, "WETH");
14138 assert_eq!(balances[1].currency.name, "Wrapped Ether");
14139 assert_eq!(balances[1].currency.precision, 18);
14140 assert_eq!(balances[1].total.raw(), 1_234_567_890_123_456_789);
14141 assert_eq!(balances[1].free, balances[1].total);
14142 assert_eq!(balances[1].locked, Money::zero(balances[1].currency));
14143 assert_eq!(balances[2].currency.code, "USDC");
14144 assert_eq!(balances[2].currency.name, "USD Coin");
14145 assert_eq!(balances[2].currency.precision, 6);
14146 assert_eq!(balances[2].total.raw(), 9_876_543_210_000_000_000);
14147 assert_eq!(balances[2].free, balances[2].total);
14148 assert_eq!(balances[2].locked, Money::zero(balances[2].currency));
14149
14150 client.refresh_wallet_balances().await.unwrap();
14151 let balances = client.wallet_balance.lock().as_account_balances().unwrap();
14152
14153 assert_eq!(balances.len(), 3);
14154 assert_eq!(client.wallet_balance.lock().token_balances.len(), 2);
14155 assert_eq!(balances[0].total.raw(), 0);
14156 assert_eq!(balances[1].total.raw(), 2_000_000_000_000_000_000);
14157 assert_eq!(balances[2].total.raw(), 12_345_670_000_000_000);
14158 }
14159
14160 #[allow(unsafe_code)] #[tokio::test]
14162 async fn failed_connect_refresh_retains_snapshot_and_publishes_nothing() {
14163 let state = execution_rpc_state()
14164 .with_response_sequence("eth_getBalance", &[GET_BALANCE, GET_BALANCE_ZERO])
14165 .with_response_sequence(
14166 "eth_call",
14167 &[
14168 CALL_BALANCE_WETH,
14169 CALL_BALANCE_USDC,
14170 CALL_BALANCE_WETH_UPDATED,
14171 RPC_METHOD_NOT_FOUND,
14172 ],
14173 );
14174 let (mut client, _, _) =
14175 client_with_token_mock_rpc(state, "BLOCKCHAIN_TEST_BALANCE_ATOMIC").await;
14176 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
14177 replace_exec_event_sender(sender);
14178 client.start().unwrap();
14179 client.refresh_wallet_balances().await.unwrap();
14180 let retained = client.wallet_balance.lock().as_account_balances().unwrap();
14181 receiver.try_recv().unwrap();
14182 unsafe { std::env::set_var("BLOCKCHAIN_TEST_BALANCE_ATOMIC", TEST_PRIVATE_KEY) };
14184
14185 let error = client.connect().await.unwrap_err();
14186 let failed_address = Address::from_str(USDC).unwrap();
14187
14188 assert!(
14189 error.to_string().contains(&format!(
14190 "failed to fetch token balance for {failed_address}"
14191 )),
14192 "was: {error}"
14193 );
14194 assert!(!client.is_connected());
14195 assert!(client.signer.is_none());
14196 assert_eq!(
14197 client.wallet_balance.lock().as_account_balances().unwrap(),
14198 retained
14199 );
14200 assert!(matches!(
14201 receiver.try_recv(),
14202 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
14203 ));
14204 }
14205
14206 #[allow(unsafe_code)] #[tokio::test]
14208 async fn failed_connect_publication_retains_snapshot() {
14209 let state = execution_rpc_state()
14210 .with_response_sequence("eth_getBalance", &[GET_BALANCE, GET_BALANCE_ZERO])
14211 .with_response_sequence(
14212 "eth_call",
14213 &[
14214 CALL_BALANCE_WETH,
14215 CALL_BALANCE_USDC,
14216 CALL_BALANCE_WETH_UPDATED,
14217 CALL_BALANCE_USDC_UPDATED,
14218 ],
14219 );
14220 let (mut client, _, _) =
14221 client_with_token_mock_rpc(state, "BLOCKCHAIN_TEST_BALANCE_PUBLICATION").await;
14222 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
14223 replace_exec_event_sender(sender);
14224 client.start().unwrap();
14225 client.refresh_wallet_balances().await.unwrap();
14226 let retained = client.wallet_balance.lock().as_account_balances().unwrap();
14227 receiver.try_recv().unwrap();
14228 drop(receiver);
14229 unsafe { std::env::set_var("BLOCKCHAIN_TEST_BALANCE_PUBLICATION", TEST_PRIVATE_KEY) };
14231
14232 let error = client.connect().await.unwrap_err();
14233
14234 assert!(
14235 error.to_string().contains("Failed to send account state"),
14236 "was: {error}"
14237 );
14238 assert!(!client.is_connected());
14239 assert!(client.signer.is_none());
14240 assert_eq!(
14241 client.wallet_balance.lock().as_account_balances().unwrap(),
14242 retained
14243 );
14244 }
14245
14246 #[allow(unsafe_code)] #[tokio::test]
14248 async fn connect_and_repeated_query_publish_wallet_account_state() {
14249 let state = execution_rpc_state()
14250 .with_response_sequence("eth_call", &[CALL_BALANCE_WETH, CALL_BALANCE_USDC]);
14251 let (mut client, state, cache) =
14252 client_with_token_mock_rpc(state, "BLOCKCHAIN_TEST_ACCOUNT_STATE").await;
14253 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
14254 replace_exec_event_sender(sender);
14255 client.start().unwrap();
14256 unsafe { std::env::set_var("BLOCKCHAIN_TEST_ACCOUNT_STATE", TEST_PRIVATE_KEY) };
14258
14259 client.connect().await.unwrap();
14260
14261 let ExecutionEvent::Account(connected) = receiver.try_recv().unwrap() else {
14262 panic!("expected account state event")
14263 };
14264 assert_eq!(connected.account_id, AccountId::from("BLOCKCHAIN-001"));
14265 assert_eq!(connected.account_type, AccountType::Wallet);
14266 assert_eq!(connected.base_currency, None);
14267 assert_eq!(connected.balances.len(), 3);
14268 assert!(connected.margins.is_empty());
14269 assert!(connected.is_reported);
14270 cache.borrow_mut().update_account_state(&connected).unwrap();
14271
14272 let account = client.get_account().unwrap();
14273 assert!(matches!(account, AccountAny::Wallet(_)));
14274 assert_eq!(account.id(), AccountId::from("BLOCKCHAIN-001"));
14275 assert_eq!(account.last_event(), Some(connected.clone()));
14276
14277 let requests_before = state.recorded_requests().len();
14278 let query = || {
14279 QueryAccount::new(
14280 TraderId::from("TRADER-001"),
14281 Some(ClientId::from("BLOCKCHAIN-001")),
14282 AccountId::from("BLOCKCHAIN-001"),
14283 UUID4::new(),
14284 UnixNanos::default(),
14285 None,
14286 None,
14287 )
14288 };
14289 client.query_account(query()).unwrap();
14290 client.query_account(query()).unwrap();
14291
14292 let ExecutionEvent::Account(first_query) = receiver.try_recv().unwrap() else {
14293 panic!("expected first query account state event")
14294 };
14295 let ExecutionEvent::Account(second_query) = receiver.try_recv().unwrap() else {
14296 panic!("expected second query account state event")
14297 };
14298 assert!(connected.has_same_balances_and_margins(&first_query));
14299 assert!(first_query.has_same_balances_and_margins(&second_query));
14300 assert_eq!(first_query.account_id, connected.account_id);
14301 assert_eq!(first_query.account_type, connected.account_type);
14302 assert_eq!(first_query.base_currency, connected.base_currency);
14303 assert_eq!(first_query.is_reported, connected.is_reported);
14304 assert_ne!(first_query.event_id, second_query.event_id);
14305 assert_eq!(state.recorded_requests().len(), requests_before);
14306
14307 client.stop().unwrap();
14308 assert!(client.core.is_stopped());
14309 assert!(!client.is_connected());
14310 assert!(client.signer.is_none());
14311 }
14312
14313 #[tokio::test]
14314 async fn connect_rejects_on_chain_mismatch() {
14315 let state = ready_rpc_state().with_response("eth_chainId", CHAIN_ID_ETHEREUM);
14316 let (mut client, _) = client_with_mock_rpc(state).await;
14317
14318 let error = client.connect().await.unwrap_err();
14319
14320 assert!(
14321 error
14322 .to_string()
14323 .contains("Blockchain chain ID verification disagreed"),
14324 "was: {error}"
14325 );
14326 }
14327
14328 #[allow(unsafe_code)] #[tokio::test]
14330 async fn connect_rejects_on_signer_wallet_mismatch() {
14331 let addr = start_mock_rpc_server(ready_rpc_state()).await;
14332 let config = test_config_with_signer_env(
14333 format!("http://{addr}"),
14334 "BLOCKCHAIN_TEST_PRIVATE_KEY_MISMATCH",
14335 );
14336 let mut client = test_client_from_config(config, test_pool());
14337 unsafe {
14340 std::env::set_var(
14341 "BLOCKCHAIN_TEST_PRIVATE_KEY_MISMATCH",
14342 "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
14343 )
14344 };
14345
14346 let error = client.connect().await.unwrap_err();
14347
14348 assert!(
14349 error
14350 .to_string()
14351 .contains("does not match configured wallet address"),
14352 "was: {error}"
14353 );
14354 }
14355
14356 #[allow(unsafe_code)] #[tokio::test]
14358 async fn connect_rejects_missing_trace_capability_before_signer_load() {
14359 let state = ready_rpc_state().with_parameter_response(
14360 "debug_traceTransaction",
14361 &B256::ZERO.to_string(),
14362 RPC_METHOD_NOT_FOUND,
14363 );
14364 let addr = start_mock_rpc_server(state.clone()).await;
14365 let config = test_config_with_signer_env(
14366 format!("http://{addr}"),
14367 "BLOCKCHAIN_TEST_TRACE_CAPABILITY",
14368 );
14369 let mut client = test_client_from_config(config, test_pool());
14370 unsafe { std::env::set_var("BLOCKCHAIN_TEST_TRACE_CAPABILITY", TEST_PRIVATE_KEY) };
14372
14373 let error = client.connect().await.unwrap_err();
14374
14375 assert!(
14376 error
14377 .to_string()
14378 .contains("Blockchain call trace capability verification is locally invalid"),
14379 "was: {error}"
14380 );
14381 assert!(client.signer.is_none());
14382 assert!(
14383 state
14384 .recorded_requests()
14385 .iter()
14386 .all(|request| request["method"] != "eth_getBalance")
14387 );
14388 }
14389
14390 #[allow(unsafe_code)] #[tokio::test]
14392 async fn connect_initializes_signer_from_env() {
14393 let addr = start_mock_rpc_server(ready_rpc_state()).await;
14394 let config =
14395 test_config_with_signer_env(format!("http://{addr}"), "BLOCKCHAIN_TEST_PRIVATE_KEY_OK");
14396 let mut client = test_client_from_config(config, test_pool());
14397 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14398 replace_exec_event_sender(sender);
14399 client.start().unwrap();
14400 unsafe { std::env::set_var("BLOCKCHAIN_TEST_PRIVATE_KEY_OK", TEST_PRIVATE_KEY) };
14402
14403 client.connect().await.unwrap();
14404
14405 let signer = client.signer.as_ref().unwrap();
14406 assert_eq!(
14407 signer.address(),
14408 address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266")
14409 );
14410 }
14411
14412 #[allow(unsafe_code)] #[tokio::test]
14414 async fn reconnect_resumes_at_the_durable_finalized_tip() {
14415 let Some((admin_pool, pg_config)) =
14416 connect_test_postgres("verification ledger resume").await
14417 else {
14418 return;
14419 };
14420 let schema = format!("verification_ledger_resume_{}", std::process::id());
14421 setup_execution_schema(&admin_pool, &schema).await;
14422 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
14423 let options = options.options([("search_path", schema.clone())]);
14424 let database = connect_test_database(options).await.unwrap();
14425 database
14426 .ensure_execution_transaction_schema()
14427 .await
14428 .unwrap();
14429
14430 let state = ready_rpc_state();
14431 let addr = start_mock_rpc_server(state.clone()).await;
14432 let config = test_config_with_signer_env(
14433 format!("http://{addr}"),
14434 "BLOCKCHAIN_TEST_VERIFICATION_RESUME",
14435 );
14436 let mut client = test_client_from_config(config, test_pool());
14437 client.cache.database = Some(database);
14438 protect_test_storage(&mut client, &schema).await;
14439 let finalized_headers = [
14440 ExecutionVerifiedHeader {
14441 number: FIXTURE_BLOCK,
14442 hash: FIXTURE_BLOCK_HASH.to_string(),
14443 parent_hash: "0x0000000000000000000000000000000000000000000000000000000000000001"
14444 .to_string(),
14445 timestamp: FIXTURE_BLOCK_TIMESTAMP,
14446 base_fee_per_gas: Some(100_000_000),
14447 },
14448 ExecutionVerifiedHeader {
14449 number: FIXTURE_BLOCK + 1,
14450 hash: B256::from([0x22; 32]).to_string(),
14451 parent_hash: FIXTURE_BLOCK_HASH.to_string(),
14452 timestamp: FIXTURE_BLOCK_TIMESTAMP + 1,
14453 base_fee_per_gas: Some(100_000_000),
14454 },
14455 ExecutionVerifiedHeader {
14456 number: FIXTURE_BLOCK + 2,
14457 hash: B256::from([0x33; 32]).to_string(),
14458 parent_hash: B256::from([0x22; 32]).to_string(),
14459 timestamp: FIXTURE_BLOCK_TIMESTAMP + 2,
14460 base_fee_per_gas: Some(100_000_000),
14461 },
14462 ];
14463 initialize_test_verification_ledger_with_headers(&client, &finalized_headers).await;
14464 let verification = client.config.verification.as_ref().unwrap();
14465 let position = client
14466 .cache
14467 .database
14468 .as_ref()
14469 .unwrap()
14470 .load_execution_verification_position(
14471 42_161,
14472 WALLET,
14473 &verification.manifest_version,
14474 &verification.manifest_digest,
14475 )
14476 .await
14477 .unwrap()
14478 .unwrap();
14479 let resume = client
14480 .cache
14481 .database
14482 .as_ref()
14483 .unwrap()
14484 .load_execution_verification_resume(
14485 42_161,
14486 WALLET,
14487 &verification.manifest_version,
14488 &verification.manifest_digest,
14489 )
14490 .await
14491 .unwrap()
14492 .unwrap();
14493 assert_eq!(position.next_canonical_nonce, 7);
14494 assert_eq!(position.revision, 0);
14495 assert_eq!(position.finalized_tip, finalized_headers[2]);
14496 assert_eq!(resume.next_canonical_nonce, 7);
14497 assert_eq!(resume.revision, 0);
14498 assert_eq!(resume.finalized_headers, finalized_headers);
14499
14500 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14501 replace_exec_event_sender(sender);
14502 client.start().unwrap();
14503 unsafe { std::env::set_var("BLOCKCHAIN_TEST_VERIFICATION_RESUME", TEST_PRIVATE_KEY) };
14505
14506 client.connect().await.unwrap();
14507
14508 let skipped_height = format!("0x{:x}", FIXTURE_BLOCK + 1);
14509 assert!(client.is_connected());
14510 assert!(state.recorded_requests().iter().all(|request| {
14511 request["method"] != "eth_getBlockByNumber"
14512 || request["params"][0].as_str() != Some(&skipped_height)
14513 }));
14514 let header_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
14515 "SELECT COUNT(*) FROM {schema}.execution_verified_finalized_header"
14516 )))
14517 .fetch_one(&admin_pool)
14518 .await
14519 .unwrap();
14520 assert_eq!(header_count, 3);
14521
14522 drop(client);
14523 drop_execution_schema(&admin_pool, &schema).await;
14524 }
14525
14526 #[tokio::test]
14527 async fn disconnect_revokes_signer_and_blocks_execution() {
14528 let (mut client, state) = client_with_mock_rpc(ready_rpc_state()).await;
14529 client.core.set_connected();
14530 client.signer = Some(Arc::new(
14531 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
14532 ));
14533
14534 client.disconnect().await.unwrap();
14535 let error = client.wrap(U256::from(1_000u64)).await.unwrap_err();
14536
14537 assert!(!client.is_connected());
14538 assert!(client.signer.is_none());
14539 assert!(
14540 error.to_string().contains("is not connected"),
14541 "was: {error}"
14542 );
14543 assert!(state.recorded_requests().is_empty());
14544 }
14545
14546 #[allow(unsafe_code)] #[tokio::test]
14548 async fn connect_releases_stale_preparing_claim_after_tasks_finish() {
14549 let addr = start_mock_rpc_server(ready_rpc_state()).await;
14550 let config = test_config_with_signer_env(
14551 format!("http://{addr}"),
14552 "BLOCKCHAIN_TEST_RECONNECT_CLAIM",
14553 );
14554 let mut client = test_client_from_config(config, test_pool());
14555 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14556 replace_exec_event_sender(sender);
14557 client.start().unwrap();
14558 unsafe { std::env::set_var("BLOCKCHAIN_TEST_RECONNECT_CLAIM", TEST_PRIVATE_KEY) };
14560 *client.in_flight.lock() = Some(InFlightSlot::Preparing(TransactionPurpose::Swap));
14561 client
14562 .pending_tasks
14563 .spawn(async {})
14564 .expect("stale task spawn");
14565
14566 client.connect().await.unwrap();
14567
14568 assert!(client.in_flight.lock().is_none());
14569 }
14570
14571 #[allow(unsafe_code)] #[tokio::test]
14573 async fn reconnect_after_aborted_submission_releases_preparing_claim() {
14574 let state = ready_rpc_state().with_sleep("eth_getBlockByNumber", Duration::from_secs(30));
14577 let Some((admin_pool, schema, mut client, state, _)) =
14578 swap_client_with_database("execution_reconnect_claim_test", state).await
14579 else {
14580 return;
14581 };
14582 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
14583 replace_exec_event_sender(sender);
14584 client.start().unwrap();
14585 unsafe { std::env::set_var("BLOCKCHAIN_TEST_PRIVATE_KEY", TEST_PRIVATE_KEY) };
14587
14588 let order = test_market_sell_order(test_pool().instrument_id);
14589 client.submit_order(submit_order_cmd(&order)).unwrap();
14590 await_recorded_requests(&state, "eth_getBlockByNumber", 1).await;
14591 assert!(client.in_flight.lock().is_some());
14592
14593 client.disconnect().await.unwrap();
14594 let ready_addr = start_mock_rpc_server(ready_rpc_state()).await;
14595 let ready = test_client(format!("http://{ready_addr}"));
14596 client.http_rpc_client = ready.http_rpc_client;
14597 client.verification = ready.verification;
14598 client.connect().await.unwrap();
14599
14600 assert!(client.in_flight.lock().is_none());
14601
14602 drop_execution_schema(&admin_pool, &schema).await;
14603 }
14604
14605 #[tokio::test]
14606 async fn poll_for_receipt_returns_none_after_exhaustion() {
14607 let state = ready_rpc_state().with_response("eth_getTransactionReceipt", RECEIPT_NULL);
14608 let (client, state) = client_with_mock_rpc(state).await;
14609
14610 let receipt = poll_for_receipt(&client.http_rpc_client, &B256::ZERO, 3, Duration::ZERO)
14611 .await
14612 .unwrap();
14613
14614 assert!(receipt.is_none());
14615 let requests = state.recorded_requests();
14616 assert_eq!(requests.len(), 3);
14617 }
14618
14619 #[tokio::test]
14620 async fn poll_for_receipt_returns_last_error_when_every_poll_fails() {
14621 let state =
14622 ready_rpc_state().with_response("eth_getTransactionReceipt", RPC_METHOD_NOT_FOUND);
14623 let (client, state) = client_with_mock_rpc(state).await;
14624
14625 let error = poll_for_receipt(&client.http_rpc_client, &B256::ZERO, 3, Duration::ZERO)
14626 .await
14627 .unwrap_err();
14628
14629 assert_eq!(
14630 error.to_string(),
14631 "eth_getTransactionReceipt RPC error -32601"
14632 );
14633 let requests = state.recorded_requests();
14634 assert_eq!(requests.len(), 3);
14635 }
14636
14637 #[tokio::test]
14638 async fn poll_for_receipt_returns_none_after_pending_then_errors() {
14639 let state = ready_rpc_state().with_response_sequence(
14640 "eth_getTransactionReceipt",
14641 &[RECEIPT_NULL, RPC_METHOD_NOT_FOUND, RPC_METHOD_NOT_FOUND],
14642 );
14643 let (client, state) = client_with_mock_rpc(state).await;
14644
14645 let receipt = poll_for_receipt(&client.http_rpc_client, &B256::ZERO, 3, Duration::ZERO)
14646 .await
14647 .unwrap();
14648
14649 assert!(receipt.is_none());
14650 let requests = state.recorded_requests();
14651 assert_eq!(requests.len(), 3);
14652 }
14653
14654 #[tokio::test]
14655 async fn cancellation_during_persistence_keeps_in_flight_slot() {
14656 let Some((admin_pool, pg_config)) = connect_test_postgres("persistence cancellation").await
14657 else {
14658 return;
14659 };
14660
14661 let schema = format!("execution_persist_cancel_test_{}", std::process::id());
14662 setup_execution_schema(&admin_pool, &schema).await;
14663
14664 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
14665 let db_options = db_options.options([("search_path", schema.clone())]);
14666 let database = connect_test_database(db_options).await.unwrap();
14667
14668 let state = signing_rpc_state();
14669 let addr = start_mock_rpc_server(state.clone()).await;
14670
14671 let mut client = test_client(format!("http://{addr}"));
14672 client.cache.database = Some(database);
14673 client
14675 .cache
14676 .ensure_execution_transaction_schema()
14677 .await
14678 .unwrap();
14679 protect_test_storage(&mut client, &schema).await;
14680 initialize_test_verification_ledger(&client).await;
14681 client.signer = Some(Arc::new(
14682 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
14683 ));
14684 client.core.set_connected();
14685
14686 let advisory_lock = i64::from(std::process::id());
14687
14688 for statement in [
14689 format!(
14690 "CREATE FUNCTION {schema}.block_execution_hash_insert() RETURNS trigger \
14691 LANGUAGE plpgsql AS 'BEGIN PERFORM pg_advisory_xact_lock({advisory_lock}); \
14692 RETURN NEW; END'"
14693 ),
14694 format!(
14695 "CREATE TRIGGER block_execution_hash_insert BEFORE INSERT ON \
14696 {schema}.execution_transaction_hash FOR EACH ROW EXECUTE FUNCTION \
14697 {schema}.block_execution_hash_insert()"
14698 ),
14699 ] {
14700 sqlx::query(sqlx::AssertSqlSafe(statement))
14701 .execute(&admin_pool)
14702 .await
14703 .unwrap();
14704 }
14705 let mut lock_transaction = admin_pool.begin().await.unwrap();
14706 sqlx::query("SELECT pg_advisory_xact_lock($1)")
14707 .bind(advisory_lock)
14708 .execute(&mut *lock_transaction)
14709 .await
14710 .unwrap();
14711
14712 let value = U256::from(1_000_000_000_000_000u64);
14713 let in_flight = Arc::clone(&client.in_flight);
14714 let mut wrap = Box::pin(client.wrap(value));
14715 tokio::time::timeout(Duration::from_secs(2), async {
14716 loop {
14717 tokio::select! {
14718 result = &mut wrap => {
14719 panic!("persistence completed while the table was locked: {result:?}")
14720 }
14721 () = tokio::time::sleep(Duration::from_millis(1)) => {}
14722 }
14723
14724 if matches!(*in_flight.lock(), Some(InFlightSlot::AwaitingFinality(_))) {
14725 break;
14726 }
14727 }
14728 })
14729 .await
14730 .unwrap();
14731 drop(wrap);
14732 lock_transaction.rollback().await.unwrap();
14733
14734 let slot = *client.in_flight.lock();
14735 let second_error = client
14736 .wrap(U256::from(2_000_000_000_000_000u64))
14737 .await
14738 .unwrap_err();
14739 let broadcasts = state
14740 .recorded_requests()
14741 .into_iter()
14742 .filter(|request| request["method"] == "eth_sendRawTransaction")
14743 .count();
14744 let status: String = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
14745 "SELECT status FROM {schema}.execution_intent"
14746 )))
14747 .fetch_one(&admin_pool)
14748 .await
14749 .unwrap();
14750
14751 assert!(matches!(slot, Some(InFlightSlot::AwaitingFinality(_))));
14752 assert_eq!(status, "prepared");
14753 assert!(
14754 second_error.to_string().contains("awaiting finality"),
14755 "was: {second_error}"
14756 );
14757 assert_eq!(broadcasts, 0);
14758
14759 drop_execution_schema(&admin_pool, &schema).await;
14760 }
14761
14762 #[tokio::test]
14763 async fn reservation_failure_releases_preparing_slot() {
14764 let state = signing_rpc_state();
14765 let Some((admin_pool, schema, mut client, state)) =
14766 execution_client_with_database("execution_reservation_fail_test", state).await
14767 else {
14768 return;
14769 };
14770 sqlx::query(sqlx::AssertSqlSafe(format!(
14771 "DROP TABLE {schema}.execution_intent CASCADE"
14772 )))
14773 .execute(&admin_pool)
14774 .await
14775 .unwrap();
14776
14777 let error = client
14778 .wrap(U256::from(1_000_000_000_000_000u64))
14779 .await
14780 .unwrap_err();
14781 let retry_error = client
14782 .wrap(U256::from(2_000_000_000_000_000u64))
14783 .await
14784 .unwrap_err();
14785 let broadcasts = state
14786 .recorded_requests()
14787 .into_iter()
14788 .filter(|request| request["method"] == "eth_sendRawTransaction")
14789 .count();
14790
14791 assert_eq!(
14792 error.to_string(),
14793 "Execution intent reservation failed before commit"
14794 );
14795 assert!(reservation_failure_proven_not_committed(&error));
14796 assert_eq!(
14797 retry_error.to_string(),
14798 "Execution intent reservation failed before commit"
14799 );
14800 assert_eq!(broadcasts, 0);
14801 assert!(client.in_flight.lock().is_none());
14802
14803 drop_execution_schema(&admin_pool, &schema).await;
14804 }
14805
14806 #[tokio::test]
14807 async fn reservation_commit_failure_keeps_preparing_slot() {
14808 let state = signing_rpc_state();
14809 let Some((admin_pool, schema, mut client, state)) =
14810 execution_client_with_database("execution_reservation_commit_fail_test", state).await
14811 else {
14812 return;
14813 };
14814 install_reservation_commit_rejection(&admin_pool, &schema).await;
14815
14816 let error = client
14817 .wrap(U256::from(1_000_000_000_000_000u64))
14818 .await
14819 .unwrap_err();
14820 let slot = *client.in_flight.lock();
14821 let second_error = client
14822 .wrap(U256::from(2_000_000_000_000_000u64))
14823 .await
14824 .unwrap_err();
14825 let requests = state.recorded_requests();
14826 let intent_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
14827 "SELECT COUNT(*) FROM {schema}.execution_intent"
14828 )))
14829 .fetch_one(&admin_pool)
14830 .await
14831 .unwrap();
14832 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
14833 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
14834 )))
14835 .fetch_one(&admin_pool)
14836 .await
14837 .unwrap();
14838
14839 assert_eq!(
14840 error.to_string(),
14841 "Execution intent reservation commit outcome is unknown; reconciliation is required"
14842 );
14843 assert!(!reservation_failure_proven_not_committed(&error));
14844 assert!(matches!(
14845 slot,
14846 Some(InFlightSlot::Preparing(TransactionPurpose::Wrap))
14847 ));
14848 assert!(
14849 second_error.to_string().contains("being prepared"),
14850 "was: {second_error}"
14851 );
14852 assert_eq!(intent_count, 0);
14853 assert_eq!(signed_count, 0);
14854 assert!(
14855 requests
14856 .iter()
14857 .all(|request| request["method"] != "eth_getTransactionCount")
14858 );
14859 assert!(
14860 requests
14861 .iter()
14862 .all(|request| request["method"] != "eth_sendRawTransaction")
14863 );
14864
14865 drop_execution_schema(&admin_pool, &schema).await;
14866 }
14867
14868 #[tokio::test]
14869 async fn persistence_failure_keeps_unbroadcast_slot() {
14870 let state = signing_rpc_state();
14871 let Some((admin_pool, schema, mut client, state)) =
14872 execution_client_with_database("execution_persist_fail_test", state).await
14873 else {
14874 return;
14875 };
14876 sqlx::query(sqlx::AssertSqlSafe(format!(
14877 "ALTER TABLE {schema}.execution_transaction_hash \
14878 ADD CONSTRAINT execution_hash_reject CHECK (FALSE)"
14879 )))
14880 .execute(&admin_pool)
14881 .await
14882 .unwrap();
14883
14884 let value = U256::from(1_000_000_000_000_000u64);
14885 let error = client.wrap(value).await.unwrap_err();
14886 let in_flight = awaiting_in_flight(&client);
14887 let second_error = client
14888 .wrap(U256::from(2_000_000_000_000_000u64))
14889 .await
14890 .unwrap_err();
14891 let broadcasts = state
14892 .recorded_requests()
14893 .into_iter()
14894 .filter(|request| request["method"] == "eth_sendRawTransaction")
14895 .count();
14896 let expected_hash = expected_wrap_tx_hash(value).await;
14897
14898 let error_message = error.to_string();
14899 assert!(error_message.starts_with(&format!(
14900 "Failed to persist transaction {expected_hash}: Failed to persist signed transaction"
14901 )), "was: {error_message}");
14902 assert!(
14903 error_message.ends_with("the in-flight slot stays occupied"),
14904 "was: {error_message}"
14905 );
14906 assert_eq!(in_flight.nonce, 7);
14907 assert_eq!(in_flight.tx_hash, expected_hash);
14908 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
14909 assert!(
14910 second_error.to_string().contains("still awaiting finality"),
14911 "was: {second_error}"
14912 );
14913 assert_eq!(broadcasts, 0);
14914
14915 drop_execution_schema(&admin_pool, &schema).await;
14916 }
14917
14918 #[tokio::test]
14919 async fn cancellation_after_dispatch_keeps_record_and_in_flight_slot() {
14920 let state = signing_rpc_state().with_sleep(
14921 "eth_sendRawTransaction",
14922 Duration::from_secs(EXECUTION_RPC_TIMEOUT_SECS + 2),
14923 );
14924 let Some((admin_pool, schema, mut client, state)) =
14925 execution_client_with_database("execution_cancel_test", state).await
14926 else {
14927 return;
14928 };
14929
14930 let mut wrap = Box::pin(client.wrap(U256::from(1_000_000_000_000_000u64)));
14931 tokio::select! {
14932 result = &mut wrap => panic!("broadcast completed before cancellation: {result:?}"),
14933 () = await_recorded_requests(&state, "eth_sendRawTransaction", 1) => {}
14934 }
14935 drop(wrap);
14936
14937 let in_flight = awaiting_in_flight(&client);
14938 let error = client
14939 .wrap(U256::from(2_000_000_000_000_000u64))
14940 .await
14941 .unwrap_err();
14942 let record = client
14943 .cache
14944 .get_execution_transaction(42161, &in_flight.tx_hash.to_string())
14945 .await
14946 .unwrap()
14947 .unwrap();
14948 let broadcasts = state
14949 .recorded_requests()
14950 .into_iter()
14951 .filter(|request| request["method"] == "eth_sendRawTransaction")
14952 .count();
14953
14954 assert!(
14955 error.to_string().contains("still awaiting finality"),
14956 "was: {error}"
14957 );
14958 assert_eq!(record.nonce, 7);
14959 assert_eq!(record.purpose, "wrap");
14960 assert_eq!(record.status, "broadcast");
14961 assert_eq!(broadcasts, 1);
14962
14963 drop_execution_schema(&admin_pool, &schema).await;
14964 }
14965
14966 #[tokio::test]
14967 async fn cancellation_during_receipt_polling_keeps_record_and_in_flight_slot() {
14968 let state = signing_rpc_state()
14969 .with_send_raw_transaction_echo()
14970 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS)
14971 .with_sleep(
14972 "eth_getTransactionReceipt",
14973 Duration::from_secs(EXECUTION_RPC_TIMEOUT_SECS + 2),
14974 );
14975 let Some((admin_pool, schema, mut client, state)) =
14976 execution_client_with_database("execution_receipt_cancel_test", state).await
14977 else {
14978 return;
14979 };
14980
14981 let mut wrap = Box::pin(client.wrap(U256::from(1_000_000_000_000_000u64)));
14982 tokio::select! {
14983 result = &mut wrap => panic!("receipt polling completed before cancellation: {result:?}"),
14984 () = await_recorded_requests(&state, "eth_getTransactionReceipt", 3) => {}
14985 }
14986 drop(wrap);
14987
14988 let in_flight = awaiting_in_flight(&client);
14989 let second_error = client
14990 .wrap(U256::from(2_000_000_000_000_000u64))
14991 .await
14992 .unwrap_err();
14993 let record = client
14994 .cache
14995 .get_execution_transaction(42161, &in_flight.tx_hash.to_string())
14996 .await
14997 .unwrap()
14998 .unwrap();
14999 let requests = state.recorded_requests();
15000
15001 assert_eq!(in_flight.nonce, 7);
15002 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15003 assert!(
15004 second_error.to_string().contains("still awaiting finality"),
15005 "was: {second_error}"
15006 );
15007 assert_eq!(record.nonce, 7);
15008 assert_eq!(record.transaction_hash, in_flight.tx_hash.to_string());
15009 assert_eq!(record.purpose, "wrap");
15010 assert_eq!(record.status, "broadcast");
15011 assert_eq!(
15012 requests
15013 .iter()
15014 .filter(|request| request["method"] == "eth_sendRawTransaction")
15015 .count(),
15016 1
15017 );
15018 assert_eq!(
15019 requests
15020 .iter()
15021 .filter(|request| request["method"] == "eth_getTransactionReceipt")
15022 .count(),
15023 3
15024 );
15025
15026 drop_execution_schema(&admin_pool, &schema).await;
15027 }
15028
15029 #[tokio::test]
15030 async fn rejected_broadcast_stays_dropped_and_occupied() {
15031 let state = signing_rpc_state()
15032 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_REJECTED)
15033 .with_response("eth_getTransactionReceipt", RECEIPT_NULL);
15034 let Some((admin_pool, schema, mut client, state)) =
15035 execution_client_with_database("execution_rejected_test", state).await
15036 else {
15037 return;
15038 };
15039
15040 let error = client
15041 .wrap(U256::from(1_000_000_000_000_000u64))
15042 .await
15043 .unwrap_err();
15044 let (purpose, status): (String, String) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
15045 "SELECT purpose, status FROM {schema}.execution_intent"
15046 )))
15047 .fetch_one(&admin_pool)
15048 .await
15049 .unwrap();
15050 let broadcasts = state
15051 .recorded_requests()
15052 .into_iter()
15053 .filter(|request| request["method"] == "eth_sendRawTransaction")
15054 .count();
15055
15056 assert!(
15057 error.to_string().contains("Timed out awaiting finality"),
15058 "was: {error}"
15059 );
15060 assert!(client.in_flight.lock().is_some());
15061 assert_eq!(purpose, "wrap");
15062 assert_eq!(status, "dropped");
15063 assert_eq!(broadcasts, 1);
15064
15065 drop_execution_schema(&admin_pool, &schema).await;
15066 }
15067
15068 #[tokio::test]
15069 async fn legacy_table_loss_does_not_release_rejected_broadcast() {
15070 let state = signing_rpc_state()
15071 .with_response("eth_sendRawTransaction", SEND_RAW_TRANSACTION_REJECTED)
15072 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
15073 .with_sleep("eth_sendRawTransaction", Duration::from_secs(1));
15074 let Some((admin_pool, schema, mut client, state)) =
15075 execution_client_with_database("execution_rejected_update_test", state).await
15076 else {
15077 return;
15078 };
15079
15080 let mut wrap = Box::pin(client.wrap(U256::from(1_000_000_000_000_000u64)));
15081 tokio::select! {
15082 result = &mut wrap => panic!("broadcast completed before database failure: {result:?}"),
15083 () = await_recorded_requests(&state, "eth_sendRawTransaction", 1) => {}
15084 }
15085 sqlx::query(sqlx::AssertSqlSafe(format!(
15086 "DROP TABLE {schema}.execution_transaction"
15087 )))
15088 .execute(&admin_pool)
15089 .await
15090 .unwrap();
15091
15092 let error = wrap.await.unwrap_err();
15093
15094 assert!(
15095 error.to_string().contains("Timed out awaiting finality"),
15096 "was: {error}"
15097 );
15098 let in_flight = awaiting_in_flight(&client);
15099 assert_eq!(in_flight.nonce, 7);
15100 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15101
15102 drop_execution_schema(&admin_pool, &schema).await;
15103 }
15104
15105 #[tokio::test]
15106 async fn finalized_receipt_keeps_slot_when_status_update_fails() {
15107 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000u64)).await;
15108 let block = finalized_wrap_block(expected_hash);
15109 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
15110 let state = with_finalized_identity(
15111 signing_rpc_state()
15112 .with_send_raw_transaction_echo()
15113 .with_response("eth_getTransactionReceipt", &receipt)
15114 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
15115 .with_call_response_sequence(
15116 BALANCE_OF_SELECTOR,
15117 &[
15118 CALL_BALANCE,
15119 CALL_BALANCE,
15120 CALL_BALANCE,
15121 CALL_BALANCE,
15122 CALL_BALANCE,
15123 CALL_BALANCE,
15124 CALL_BALANCE_AFTER_WRAP,
15125 CALL_BALANCE_AFTER_WRAP,
15126 CALL_BALANCE_AFTER_WRAP,
15127 ],
15128 ),
15129 &block,
15130 &receipt,
15131 );
15132 let Some((admin_pool, schema, mut client, state)) =
15133 execution_client_with_database("execution_finalized_update_test", state).await
15134 else {
15135 return;
15136 };
15137
15138 for statement in [
15139 format!(
15140 "CREATE FUNCTION {schema}.reject_finalized_status() RETURNS trigger \
15141 LANGUAGE plpgsql AS 'BEGIN IF NEW.status = ''finalized'' THEN \
15142 RAISE EXCEPTION ''test finalized status rejection''; END IF; \
15143 RETURN NEW; END'"
15144 ),
15145 format!(
15146 "CREATE TRIGGER reject_finalized_status BEFORE UPDATE ON \
15147 {schema}.execution_transaction_hash FOR EACH ROW \
15148 EXECUTE FUNCTION {schema}.reject_finalized_status()"
15149 ),
15150 ] {
15151 sqlx::query(sqlx::AssertSqlSafe(statement))
15152 .execute(&admin_pool)
15153 .await
15154 .unwrap();
15155 }
15156
15157 let error = client
15158 .wrap(U256::from(1_000_000_000_000_000u64))
15159 .await
15160 .unwrap_err();
15161 let in_flight = awaiting_in_flight(&client);
15162 let requests = state.recorded_requests();
15163
15164 assert!(
15165 error
15166 .to_string()
15167 .contains("test finalized status rejection"),
15168 "was: {error}"
15169 );
15170 assert_eq!(in_flight.nonce, 7);
15171 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15172 assert_eq!(
15173 requests
15174 .iter()
15175 .filter(|request| request["method"] == "eth_sendRawTransaction")
15176 .count(),
15177 1
15178 );
15179 assert_eq!(
15180 requests
15181 .iter()
15182 .filter(|request| request["method"] == "eth_getTransactionReceipt")
15183 .count(),
15184 3
15185 );
15186
15187 drop_execution_schema(&admin_pool, &schema).await;
15188 }
15189
15190 #[tokio::test]
15191 async fn broadcast_timeout_marks_dropped_and_keeps_ownership() {
15192 let state = signing_rpc_state()
15193 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
15194 .with_sleep(
15195 "eth_sendRawTransaction",
15196 Duration::from_secs(EXECUTION_RPC_TIMEOUT_SECS + 2),
15197 );
15198 let Some((admin_pool, schema, mut client, _)) =
15199 execution_client_with_database("execution_timeout_test", state).await
15200 else {
15201 return;
15202 };
15203
15204 let error = client
15205 .wrap(U256::from(1_000_000_000_000_000u64))
15206 .await
15207 .unwrap_err();
15208
15209 assert!(
15210 error.to_string().contains("Timed out awaiting finality"),
15211 "was: {error}"
15212 );
15213 let in_flight = awaiting_in_flight(&client);
15214 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
15215 assert_eq!(in_flight.nonce, 7);
15216
15217 let record = client
15219 .cache
15220 .get_execution_transaction(42161, &in_flight.tx_hash.to_string())
15221 .await
15222 .unwrap()
15223 .unwrap();
15224 assert_eq!(record.status, "dropped");
15225
15226 drop_execution_schema(&admin_pool, &schema).await;
15227 }
15228
15229 #[tokio::test]
15230 async fn restart_reconciles_dropped_transaction_without_rebroadcast() {
15231 let initial_state = broadcast_rpc_state()
15232 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
15233 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
15234 let Some((admin_pool, schema, mut first_client, _)) =
15235 execution_client_with_database("execution_restart_test", initial_state).await
15236 else {
15237 return;
15238 };
15239 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
15240 let error = first_client
15241 .wrap(U256::from(1_000_000_000_000_000_u64))
15242 .await
15243 .unwrap_err();
15244 assert!(
15245 error.to_string().contains("Timed out awaiting finality"),
15246 "was: {error}"
15247 );
15248 let database = first_client.cache.database.as_ref().unwrap().clone();
15249 let payload_keys = first_client.payload_keys.clone();
15250 drop(first_client);
15251
15252 let block = finalized_wrap_block(expected_hash);
15253 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
15254 let restart_state = with_finalized_identity(
15255 execution_rpc_state()
15256 .with_response("eth_getTransactionReceipt", &receipt)
15257 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
15258 .with_response_sequence(
15259 "eth_call",
15260 &[
15261 CALL_BALANCE,
15262 CALL_BALANCE,
15263 CALL_BALANCE,
15264 CALL_BALANCE_AFTER_WRAP,
15265 CALL_BALANCE_AFTER_WRAP,
15266 CALL_BALANCE_AFTER_WRAP,
15267 ],
15268 ),
15269 &block,
15270 &receipt,
15271 );
15272 let addr = start_mock_rpc_server(restart_state.clone()).await;
15273 let mut restarted = test_client(format!("http://{addr}"));
15274 restarted.cache.database = Some(database);
15275 restarted.payload_keys = payload_keys;
15276 restarted.signer = Some(Arc::new(
15277 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
15278 ));
15279
15280 restarted.reconcile_unresolved_execution().await.unwrap();
15281 restarted.reconcile_unresolved_execution().await.unwrap();
15282
15283 let record = restarted
15284 .cache
15285 .get_execution_transaction(42161, &expected_hash.to_string())
15286 .await
15287 .unwrap()
15288 .unwrap();
15289 let requests = restart_state.recorded_requests();
15290 assert_eq!(record.status, "finalized");
15291 assert!(restarted.in_flight.lock().is_none());
15292 assert_eq!(
15293 requests
15294 .iter()
15295 .filter(|request| request["method"] == "eth_getTransactionReceipt")
15296 .count(),
15297 6
15298 );
15299 assert_eq!(
15300 requests
15301 .iter()
15302 .filter(|request| {
15303 request["method"] == "eth_call"
15304 && request["params"][0]["data"]
15305 .as_str()
15306 .is_some_and(|data| data.starts_with(BALANCE_OF_SELECTOR))
15307 })
15308 .count(),
15309 6
15310 );
15311 assert_eq!(
15312 requests
15313 .iter()
15314 .filter(|request| request["method"] == "eth_sendRawTransaction")
15315 .count(),
15316 0
15317 );
15318
15319 drop_execution_schema(&admin_pool, &schema).await;
15320 }
15321
15322 fn migration_test_intent(id: i64) -> ExecutionIntentRow {
15323 ExecutionIntentRow {
15324 id,
15325 schema_version: crate::execution::transaction::EXECUTION_SCHEMA_VERSION,
15326 chain_id: 42_161,
15327 wallet_address: WALLET.to_string(),
15328 nonce: None,
15329 purpose: "wrap".to_string(),
15330 status: "prepared".to_string(),
15331 client_order_id: None,
15332 trader_id: None,
15333 strategy_id: None,
15334 account_id: None,
15335 instrument_id: None,
15336 pool_address: None,
15337 transaction_to: WETH.to_string(),
15338 transaction_input: "0xd0e30db0".to_string(),
15339 transaction_value: "1".to_string(),
15340 amount_in: None,
15341 created_block: FIXTURE_BLOCK,
15342 acknowledgement_emitted: false,
15343 fill_emitted: false,
15344 terminal_emitted: false,
15345 active: true,
15346 }
15347 }
15348
15349 fn migration_nonce_verification() -> Verified<u64> {
15350 Verified {
15351 value: 7,
15352 read: crate::rpc::verification::VerificationRead::TransactionCount,
15353 provider_ids: [
15354 "authoritative".to_string(),
15355 "verifier-a".to_string(),
15356 "verifier-b".to_string(),
15357 ],
15358 normalized_value_digest: keccak256(7_u64.to_be_bytes()),
15359 }
15360 }
15361
15362 fn migration_finalized_header() -> VerifiedBlockHeader {
15363 VerifiedBlockHeader {
15364 number: FIXTURE_BLOCK,
15365 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
15366 parent_hash: B256::from_str(
15367 "0x0000000000000000000000000000000000000000000000000000000000000001",
15368 )
15369 .unwrap(),
15370 timestamp: FIXTURE_BLOCK_TIMESTAMP,
15371 base_fee_per_gas: Some(100_000_000),
15372 }
15373 }
15374
15375 #[tokio::test]
15376 async fn verification_migration_recovers_prepared_unassigned_intent() {
15377 let client = test_client("http://127.0.0.1:1".to_string());
15378 let snapshot = ExecutionVerificationMigrationSnapshot {
15379 intents: vec![migration_test_intent(1)],
15380 hashes: Vec::new(),
15381 };
15382 let finalized = migration_finalized_header();
15383 let migration = client
15384 .build_execution_verification_migration(
15385 snapshot,
15386 finalized,
15387 &[finalized],
15388 &migration_nonce_verification(),
15389 )
15390 .await
15391 .unwrap();
15392
15393 assert_eq!(migration.records.len(), 1);
15394 let record = &migration.records[0];
15395 assert_eq!(record.intent_id, 1);
15396 assert_eq!(record.nonce, None);
15397 assert_eq!(record.transaction_hash, None);
15398 assert!(record.recover_prepared);
15399 assert_eq!(record.decisions.len(), 1);
15400 }
15401
15402 #[tokio::test]
15403 async fn verification_migration_rejects_inconsistent_released_history() {
15404 let client = test_client("http://127.0.0.1:1".to_string());
15405 let finalized = migration_finalized_header();
15406 let nonce_verification = migration_nonce_verification();
15407 let mut missing_marker = migration_test_intent(1);
15408 missing_marker.active = false;
15409 missing_marker.status = "finalized".to_string();
15410 missing_marker.nonce = Some(6);
15411 let error = client
15412 .build_execution_verification_migration(
15413 ExecutionVerificationMigrationSnapshot {
15414 intents: vec![missing_marker],
15415 hashes: Vec::new(),
15416 },
15417 finalized,
15418 &[finalized],
15419 &nonce_verification,
15420 )
15421 .await
15422 .err()
15423 .unwrap();
15424 assert!(
15425 error.to_string().contains("has no durable event marker"),
15426 "was: {error}"
15427 );
15428
15429 let mut first = migration_test_intent(1);
15430 first.active = false;
15431 first.status = "recoverable".to_string();
15432 first.nonce = Some(6);
15433 let mut second = first.clone();
15434 second.id = 2;
15435 let error = client
15436 .build_execution_verification_migration(
15437 ExecutionVerificationMigrationSnapshot {
15438 intents: vec![first, second],
15439 hashes: Vec::new(),
15440 },
15441 finalized,
15442 &[finalized],
15443 &nonce_verification,
15444 )
15445 .await
15446 .err()
15447 .unwrap();
15448 assert!(
15449 error
15450 .to_string()
15451 .contains("duplicate signer nonce ownership"),
15452 "was: {error}"
15453 );
15454 }
15455
15456 #[tokio::test]
15457 async fn verification_migration_reconstructs_consumed_active_intent() {
15458 let expected_hash = expected_wrap_tx_hash(U256::from(1u64)).await;
15459 let mut block_value: serde_json::Value =
15460 serde_json::from_str(&finalized_wrap_block(expected_hash)).unwrap();
15461 block_value["result"]["transactions"][0]["value"] = serde_json::json!("0x1");
15462 let block = block_value.to_string();
15463 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
15464 let state = with_finalized_identity(
15465 execution_rpc_state()
15466 .with_response("eth_getTransactionCount", TRANSACTION_COUNT_NEXT)
15467 .with_response("eth_getTransactionReceipt", &receipt)
15468 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block),
15469 &block,
15470 &receipt,
15471 );
15472 let Some((admin_pool, pg_config)) =
15473 connect_test_postgres("verification_migration_reconstructs_consumed_active_intent")
15474 .await
15475 else {
15476 return;
15477 };
15478 let schema = format!(
15479 "verification_migration_reconstructs_consumed_active_intent_{}",
15480 std::process::id()
15481 );
15482 setup_execution_schema(&admin_pool, &schema).await;
15483 let options: sqlx::postgres::PgConnectOptions = pg_config.into();
15484 let database = connect_test_database(options.options([("search_path", schema.clone())]))
15485 .await
15486 .unwrap();
15487 let addr = start_mock_rpc_server(state).await;
15488 let (mut client, _) = swap_client_with_cache(test_config(format!("http://{addr}")));
15489 client.cache.database = Some(database.clone());
15490 client
15491 .cache
15492 .ensure_execution_transaction_schema()
15493 .await
15494 .unwrap();
15495 let (intent, persisted_hash, _) = persist_test_wrap_broadcast(&database, None).await;
15496 protect_test_storage(&mut client, &schema).await;
15497 assert_eq!(persisted_hash, expected_hash);
15498
15499 let snapshot = database
15500 .load_execution_verification_migration_snapshot(42_161, WALLET)
15501 .await
15502 .unwrap();
15503 let nonce_verification = required_verification(
15504 client
15505 .verification
15506 .verify_transaction_count(&Address::from_str(WALLET).unwrap(), FIXTURE_BLOCK + 1)
15507 .await,
15508 "test migration nonce",
15509 )
15510 .unwrap();
15511 let headers = [
15512 VerifiedBlockHeader {
15513 number: FIXTURE_BLOCK,
15514 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
15515 parent_hash: B256::from_str(
15516 "0x0000000000000000000000000000000000000000000000000000000000000001",
15517 )
15518 .unwrap(),
15519 timestamp: FIXTURE_BLOCK_TIMESTAMP,
15520 base_fee_per_gas: Some(100_000_000),
15521 },
15522 VerifiedBlockHeader {
15523 number: FIXTURE_BLOCK + 1,
15524 hash: B256::from([0x22; 32]),
15525 parent_hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
15526 timestamp: FIXTURE_BLOCK_TIMESTAMP + 1,
15527 base_fee_per_gas: Some(100_000_000),
15528 },
15529 ];
15530 let migration = client
15531 .build_execution_verification_migration(
15532 snapshot,
15533 headers[1],
15534 &headers,
15535 &nonce_verification,
15536 )
15537 .await
15538 .unwrap();
15539 let finalized_headers = headers
15540 .iter()
15541 .map(|header| ExecutionVerifiedHeader {
15542 number: header.number,
15543 hash: header.hash.to_string(),
15544 parent_hash: header.parent_hash.to_string(),
15545 timestamp: header.timestamp,
15546 base_fee_per_gas: header.base_fee_per_gas,
15547 })
15548 .collect::<Vec<_>>();
15549 let decisions = [verification_decision(
15550 &nonce_verification,
15551 Some(FIXTURE_BLOCK + 1),
15552 Some(FIXTURE_BLOCK + 1),
15553 )];
15554
15555 initialize_test_verification_migration(
15556 &client,
15557 &finalized_headers,
15558 8,
15559 &decisions,
15560 &migration,
15561 )
15562 .await;
15563
15564 let migrated = database.get_execution_intent(intent.id).await.unwrap();
15565 let resume = database
15566 .load_execution_verification_resume(
15567 42_161,
15568 WALLET,
15569 &client
15570 .config
15571 .verification
15572 .as_ref()
15573 .unwrap()
15574 .manifest_version,
15575 &client.config.verification.as_ref().unwrap().manifest_digest,
15576 )
15577 .await
15578 .unwrap()
15579 .unwrap();
15580 let evidence: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
15581 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
15582 WHERE intent_id = $1 AND decision_class = 'migration'"
15583 )))
15584 .bind(intent.id)
15585 .fetch_one(&admin_pool)
15586 .await
15587 .unwrap();
15588
15589 assert_eq!(migrated.status, "finalized");
15590 assert!(migrated.active);
15591 assert_eq!(resume.next_canonical_nonce, 8);
15592 assert!(evidence >= 5);
15593
15594 drop_execution_schema(&admin_pool, &schema).await;
15595 }
15596
15597 #[tokio::test]
15598 async fn verification_resume_accepts_one_consumed_owned_nonce() {
15599 let Some((admin_pool, schema, client, _)) = execution_client_with_database(
15600 "verification_resume_owned_nonce_test",
15601 ready_rpc_state(),
15602 )
15603 .await
15604 else {
15605 return;
15606 };
15607 let database = client.cache.database.as_ref().unwrap();
15608 persist_test_wrap_broadcast(database, client.payload_keys.as_deref()).await;
15609 let resume = database
15610 .load_execution_verification_resume(
15611 42_161,
15612 WALLET,
15613 &client
15614 .config
15615 .verification
15616 .as_ref()
15617 .unwrap()
15618 .manifest_version,
15619 &client.config.verification.as_ref().unwrap().manifest_digest,
15620 )
15621 .await
15622 .unwrap()
15623 .unwrap();
15624
15625 ensure_test_verification_ledger(&client, &resume.finalized_headers, 7, 8)
15626 .await
15627 .unwrap();
15628
15629 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
15630 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
15631 )))
15632 .fetch_one(&admin_pool)
15633 .await
15634 .unwrap();
15635 assert_eq!(nonce_state, (7, 0));
15636
15637 drop_execution_schema(&admin_pool, &schema).await;
15638 }
15639
15640 #[tokio::test]
15641 async fn verification_resume_rejects_unowned_nonce_advance() {
15642 for (test_name, mutation, observed_canonical_nonce) in [
15643 ("verification_resume_no_active_test", "no_active", 8),
15644 ("verification_resume_excess_test", "unchanged", 9),
15645 ("verification_resume_signed_test", "signed", 8),
15646 ("verification_resume_prepared_test", "prepared", 8),
15647 (
15648 "verification_resume_missing_payload_test",
15649 "missing_payload",
15650 8,
15651 ),
15652 ] {
15653 let Some((admin_pool, schema, client, _)) =
15654 execution_client_with_database(test_name, ready_rpc_state()).await
15655 else {
15656 return;
15657 };
15658 let database = client.cache.database.as_ref().unwrap();
15659 let (intent, _, _) =
15660 persist_test_wrap_broadcast(database, client.payload_keys.as_deref()).await;
15661
15662 match mutation {
15663 "no_active" => {
15664 sqlx::query(sqlx::AssertSqlSafe(format!(
15665 "UPDATE {schema}.execution_intent SET active = FALSE WHERE id = $1"
15666 )))
15667 .bind(intent.id)
15668 .execute(&admin_pool)
15669 .await
15670 .unwrap();
15671 }
15672 "signed" => {
15673 sqlx::query(sqlx::AssertSqlSafe(format!(
15674 "UPDATE {schema}.execution_intent SET status = 'signed' WHERE id = $1"
15675 )))
15676 .bind(intent.id)
15677 .execute(&admin_pool)
15678 .await
15679 .unwrap();
15680 sqlx::query(sqlx::AssertSqlSafe(format!(
15681 "UPDATE {schema}.execution_transaction_hash SET status = 'signed' \
15682 WHERE intent_id = $1"
15683 )))
15684 .bind(intent.id)
15685 .execute(&admin_pool)
15686 .await
15687 .unwrap();
15688 }
15689 "prepared" => {
15690 sqlx::query(sqlx::AssertSqlSafe(format!(
15691 "UPDATE {schema}.execution_intent \
15692 SET nonce = NULL, status = 'prepared' WHERE id = $1"
15693 )))
15694 .bind(intent.id)
15695 .execute(&admin_pool)
15696 .await
15697 .unwrap();
15698 sqlx::query(sqlx::AssertSqlSafe(format!(
15699 "UPDATE {schema}.execution_transaction_hash SET current = FALSE \
15700 WHERE intent_id = $1"
15701 )))
15702 .bind(intent.id)
15703 .execute(&admin_pool)
15704 .await
15705 .unwrap();
15706 }
15707 "missing_payload" => {
15708 sqlx::query(sqlx::AssertSqlSafe(format!(
15709 "UPDATE {schema}.execution_transaction_hash \
15710 SET payload_expected = FALSE, sealed_transaction = NULL \
15711 WHERE intent_id = $1"
15712 )))
15713 .bind(intent.id)
15714 .execute(&admin_pool)
15715 .await
15716 .unwrap();
15717 }
15718 "unchanged" => {}
15719 _ => unreachable!(),
15720 }
15721 let resume = database
15722 .load_execution_verification_resume(
15723 42_161,
15724 WALLET,
15725 &client
15726 .config
15727 .verification
15728 .as_ref()
15729 .unwrap()
15730 .manifest_version,
15731 &client.config.verification.as_ref().unwrap().manifest_digest,
15732 )
15733 .await
15734 .unwrap()
15735 .unwrap();
15736
15737 let error = ensure_test_verification_ledger(
15738 &client,
15739 &resume.finalized_headers,
15740 7,
15741 observed_canonical_nonce,
15742 )
15743 .await
15744 .unwrap_err();
15745
15746 assert!(
15747 error
15748 .to_string()
15749 .contains(if observed_canonical_nonce == 9 {
15750 "outside the owned recovery range"
15751 } else {
15752 "without"
15753 }),
15754 "{mutation}: {error}"
15755 );
15756 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
15757 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
15758 )))
15759 .fetch_one(&admin_pool)
15760 .await
15761 .unwrap();
15762 assert_eq!(nonce_state, (7, 0), "{mutation}");
15763
15764 drop_execution_schema(&admin_pool, &schema).await;
15765 }
15766 }
15767
15768 #[tokio::test]
15769 async fn restart_marks_prepared_intent_recoverable_without_broadcast() {
15770 let Some((admin_pool, schema, client, state)) =
15771 execution_client_with_database("execution_prepared_restart_test", ready_rpc_state())
15772 .await
15773 else {
15774 return;
15775 };
15776 let database = client.cache.database.as_ref().unwrap();
15777 let intent = reserve_test_wrap_intent(database).await;
15778 *client.in_flight.lock() = Some(InFlightSlot::Preparing(TransactionPurpose::Wrap));
15779
15780 client.reconcile_unresolved_execution().await.unwrap();
15781
15782 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
15783 "SELECT status, active FROM {schema}.execution_intent WHERE id = {}",
15784 intent.id
15785 )))
15786 .fetch_one(&admin_pool)
15787 .await
15788 .unwrap();
15789 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
15790 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
15791 )))
15792 .fetch_all(&admin_pool)
15793 .await
15794 .unwrap();
15795
15796 assert_eq!(status, "recoverable");
15797 assert!(!active);
15798 assert_eq!(transitions, ["prepared", "recoverable"]);
15799 assert!(client.in_flight.lock().is_none());
15800 assert!(
15801 state
15802 .recorded_requests()
15803 .iter()
15804 .all(|request| request["method"] != "eth_sendRawTransaction")
15805 );
15806
15807 drop_execution_schema(&admin_pool, &schema).await;
15808 }
15809
15810 #[tokio::test]
15811 async fn protected_restart_authenticates_envelope_before_recovery() {
15812 let initial_state = broadcast_rpc_state()
15813 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
15814 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
15815 let Some((admin_pool, schema, mut first_client, _)) =
15816 execution_client_with_database("protected_restart_test", initial_state).await
15817 else {
15818 return;
15819 };
15820 let database = first_client.cache.database.as_ref().unwrap().clone();
15821 let value = U256::from(1_000_000_000_000_000_u64);
15822 let expected_hash = expected_wrap_tx_hash(value).await;
15823
15824 let error = first_client.wrap(value).await.unwrap_err();
15825
15826 assert!(error.to_string().contains("Timed out awaiting finality"));
15827 let intent = database
15828 .get_active_execution_intent(42161, WALLET)
15829 .await
15830 .unwrap()
15831 .unwrap();
15832 let payload = database
15833 .get_execution_transaction_hashes(intent.id)
15834 .await
15835 .unwrap()
15836 .pop()
15837 .unwrap();
15838 assert!(payload.raw_transaction.is_none());
15839 assert!(payload.sealed_transaction.is_some());
15840 let keys = first_client.payload_keys.take().unwrap();
15841 drop(first_client);
15842
15843 let block = finalized_wrap_block(expected_hash);
15844 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
15845 let restart_state = with_finalized_identity(
15846 execution_rpc_state()
15847 .with_response("eth_getTransactionReceipt", &receipt)
15848 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
15849 .with_response_sequence(
15850 "eth_call",
15851 &[
15852 CALL_BALANCE,
15853 CALL_BALANCE,
15854 CALL_BALANCE,
15855 CALL_BALANCE_AFTER_WRAP,
15856 CALL_BALANCE_AFTER_WRAP,
15857 CALL_BALANCE_AFTER_WRAP,
15858 ],
15859 ),
15860 &block,
15861 &receipt,
15862 );
15863 let addr = start_mock_rpc_server(restart_state.clone()).await;
15864 let mut restarted = test_client(format!("http://{addr}"));
15865 restarted.cache.database = Some(database);
15866 restarted.payload_keys = Some(keys);
15867 restarted.signer = Some(Arc::new(
15868 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
15869 ));
15870
15871 restarted.reconcile_unresolved_execution().await.unwrap();
15872
15873 let record = restarted
15874 .cache
15875 .get_execution_transaction(42161, &expected_hash.to_string())
15876 .await
15877 .unwrap()
15878 .unwrap();
15879 let requests = restart_state.recorded_requests();
15880 assert_eq!(record.status, "finalized");
15881 assert!(restarted.in_flight.lock().is_none());
15882 assert_eq!(
15883 requests
15884 .iter()
15885 .filter(|request| request["method"] == "eth_sendRawTransaction")
15886 .count(),
15887 0
15888 );
15889
15890 drop_execution_schema(&admin_pool, &schema).await;
15891 }
15892
15893 #[tokio::test]
15894 async fn active_intent_reconciliation_waits_for_reservation_fence() {
15895 let Some((admin_pool, schema, client, _)) = execution_client_with_database(
15896 "execution_active_intent_reservation_fence_test",
15897 ready_rpc_state(),
15898 )
15899 .await
15900 else {
15901 return;
15902 };
15903 let database = client.cache.database.as_ref().unwrap().clone();
15904 let lock = PgAdvisoryLock::new(format!(
15905 "nautilus:blockchain:execution:42161:{}",
15906 WALLET.to_ascii_lowercase()
15907 ));
15908 let PgAdvisoryLockKey::BigInt(lock_key) = lock.key() else {
15909 unreachable!("string advisory locks use the 64-bit key space");
15910 };
15911 let mut reservation = admin_pool.begin().await.unwrap();
15912 sqlx::query("SELECT pg_advisory_xact_lock($1)")
15913 .bind(*lock_key)
15914 .execute(&mut *reservation)
15915 .await
15916 .unwrap();
15917 sqlx::query(sqlx::AssertSqlSafe(format!(
15918 "INSERT INTO {schema}.execution_intent (\
15919 schema_version, chain_id, wallet_address, purpose, status, transaction_to, \
15920 transaction_input, transaction_value, created_block\
15921 ) VALUES (2, $1, $2, 'wrap', 'prepared', $3, '0xd0e30db0', '1', $4)"
15922 )))
15923 .bind(42161_i32)
15924 .bind(WALLET)
15925 .bind(WETH_ADDRESS.to_string())
15926 .bind(i64::try_from(FIXTURE_BLOCK).unwrap())
15927 .execute(&mut *reservation)
15928 .await
15929 .unwrap();
15930
15931 let mut reconciliation = Box::pin(database.get_active_execution_intent(42161, WALLET));
15932 assert!(
15933 tokio::time::timeout(Duration::from_millis(100), &mut reconciliation)
15934 .await
15935 .is_err(),
15936 "reconciliation did not wait for the reservation fence"
15937 );
15938
15939 reservation.commit().await.unwrap();
15940 let intent = tokio::time::timeout(Duration::from_secs(2), reconciliation)
15941 .await
15942 .unwrap()
15943 .unwrap()
15944 .unwrap();
15945
15946 assert_eq!(intent.chain_id, 42161);
15947 assert_eq!(intent.wallet_address, WALLET);
15948 assert_eq!(intent.purpose, "wrap");
15949 assert_eq!(intent.status, "prepared");
15950 assert!(intent.active);
15951
15952 drop_execution_schema(&admin_pool, &schema).await;
15953 }
15954
15955 #[tokio::test]
15956 async fn restart_keeps_unbroadcast_signed_intent_reserved() {
15957 let Some((admin_pool, schema, client, state)) =
15958 execution_client_with_database("execution_signed_restart_test", ready_rpc_state())
15959 .await
15960 else {
15961 return;
15962 };
15963 let database = client.cache.database.as_ref().unwrap();
15964 let intent = reserve_test_wrap_intent(database).await;
15965 let transaction = build_eip1559_transaction(
15966 42161,
15967 7,
15968 78_000,
15969 130_000_000,
15970 10_000_000,
15971 WETH_ADDRESS,
15972 U256::from(1u64),
15973 Bytes::from(nautilus_core::hex::decode("d0e30db0").unwrap()),
15974 );
15975 let (tx_hash, raw_transaction) = sign_eip1559_transaction(
15976 transaction,
15977 &PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
15978 )
15979 .await
15980 .unwrap();
15981 database
15982 .assign_execution_intent_nonce(intent.id, 7)
15983 .await
15984 .unwrap();
15985 let intent = database.get_execution_intent(intent.id).await.unwrap();
15986 persist_test_payload(
15987 database,
15988 client.payload_keys.as_deref(),
15989 &intent,
15990 tx_hash,
15991 &raw_transaction,
15992 )
15993 .await;
15994
15995 let error = client.reconcile_unresolved_execution().await.unwrap_err();
15996
15997 assert!(
15998 error
15999 .to_string()
16000 .contains("was not authorized for broadcast"),
16001 "was: {error}"
16002 );
16003 let recovery = recovering_in_flight(&client);
16004 assert_eq!(recovery.intent_id, intent.id);
16005 assert_eq!(recovery.nonce, 7);
16006 assert_eq!(recovery.purpose, TransactionPurpose::Wrap);
16007 assert_eq!(
16008 execution_intent_markers(&admin_pool, &schema).await,
16009 vec![("wrap".into(), "signed".into(), false, true)]
16010 );
16011 assert!(
16012 state
16013 .recorded_requests()
16014 .iter()
16015 .all(|request| request["method"] != "eth_sendRawTransaction")
16016 );
16017
16018 drop_execution_schema(&admin_pool, &schema).await;
16019 }
16020
16021 #[tokio::test]
16022 async fn restart_quarantines_legacy_recoverable_signed_intent() {
16023 let Some((admin_pool, schema, client, state)) = execution_client_with_database(
16024 "execution_legacy_recoverable_restart_test",
16025 ready_rpc_state(),
16026 )
16027 .await
16028 else {
16029 return;
16030 };
16031 let database = client.cache.database.as_ref().unwrap();
16032 let intent = reserve_test_wrap_intent(database).await;
16033 let tx_hash = B256::from([0x55; 32]);
16034 database
16035 .assign_execution_intent_nonce(intent.id, 7)
16036 .await
16037 .unwrap();
16038 let intent = database.get_execution_intent(intent.id).await.unwrap();
16039 persist_test_payload(
16040 database,
16041 client.payload_keys.as_deref(),
16042 &intent,
16043 tx_hash,
16044 &[0x01, 0x02, 0x03],
16045 )
16046 .await;
16047 sqlx::query(sqlx::AssertSqlSafe(format!(
16048 "UPDATE {schema}.execution_intent SET status = 'recoverable', active = FALSE WHERE id = {}",
16049 intent.id
16050 )))
16051 .execute(&admin_pool)
16052 .await
16053 .unwrap();
16054
16055 let error = client.reconcile_unresolved_execution().await.unwrap_err();
16056
16057 assert!(
16058 error
16059 .to_string()
16060 .contains("retains signed transaction bytes"),
16061 "was: {error}"
16062 );
16063 assert!(client.in_flight.lock().is_none());
16064 assert!(
16065 state
16066 .recorded_requests()
16067 .iter()
16068 .all(|request| request["method"] != "eth_sendRawTransaction")
16069 );
16070
16071 drop_execution_schema(&admin_pool, &schema).await;
16072 }
16073
16074 #[rstest]
16075 #[case::current("execution_invalid_signed_restart_test", false, "broadcast")]
16076 #[case::historical("execution_invalid_historical_restart_test", true, "replaced")]
16077 #[tokio::test]
16078 async fn restart_rejects_invalid_signed_bytes_before_recovery_effects(
16079 #[case] test_name: &str,
16080 #[case] historical: bool,
16081 #[case] expected_status: &str,
16082 ) {
16083 let Some((admin_pool, schema, first_client, state, _)) =
16084 swap_client_with_database(test_name, ready_rpc_state()).await
16085 else {
16086 return;
16087 };
16088 let database = first_client.cache.database.as_ref().unwrap().clone();
16089 let (intent, _) =
16090 persist_invalid_test_swap(&database, first_client.payload_keys.as_deref()).await;
16091
16092 if historical {
16093 sqlx::query(sqlx::AssertSqlSafe(format!(
16094 "UPDATE {schema}.execution_transaction_hash \
16095 SET current = FALSE, status = 'replaced' WHERE intent_id = $1"
16096 )))
16097 .bind(intent.id)
16098 .execute(&admin_pool)
16099 .await
16100 .unwrap();
16101 sqlx::query(sqlx::AssertSqlSafe(format!(
16102 "INSERT INTO {schema}.execution_transaction_hash (\
16103 intent_id, chain_id, transaction_hash, payload_expected, status, current\
16104 ) VALUES ($1, 42161, $2, FALSE, 'replaced', TRUE)"
16105 )))
16106 .bind(intent.id)
16107 .bind(B256::from([0x44; 32]).to_string())
16108 .execute(&admin_pool)
16109 .await
16110 .unwrap();
16111 sqlx::query(sqlx::AssertSqlSafe(format!(
16112 "UPDATE {schema}.execution_intent SET status = 'replaced' WHERE id = $1"
16113 )))
16114 .bind(intent.id)
16115 .execute(&admin_pool)
16116 .await
16117 .unwrap();
16118 }
16119 let transitions_before: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16120 "SELECT COUNT(*) FROM {schema}.execution_transaction_transition"
16121 )))
16122 .fetch_one(&admin_pool)
16123 .await
16124 .unwrap();
16125 let config = first_client.config.clone();
16126 let payload_keys = first_client.payload_keys.clone();
16127 drop(first_client);
16128
16129 let (mut restarted, _) = swap_client_with_cache(config);
16130 restarted.cache.database = Some(database);
16131 restarted.payload_keys = payload_keys;
16132 restarted.signer = Some(Arc::new(
16133 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16134 ));
16135 let mut receiver = start_with_events(&mut restarted);
16136
16137 let error = restarted
16138 .reconcile_unresolved_execution()
16139 .await
16140 .unwrap_err();
16141
16142 let recovery = recovering_in_flight(&restarted);
16143 let (status, acknowledgement_emitted, active): (String, bool, bool) =
16144 sqlx::query_as(sqlx::AssertSqlSafe(format!(
16145 "SELECT status, acknowledgement_emitted, active FROM {schema}.execution_intent"
16146 )))
16147 .fetch_one(&admin_pool)
16148 .await
16149 .unwrap();
16150 let transitions_after: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16151 "SELECT COUNT(*) FROM {schema}.execution_transaction_transition"
16152 )))
16153 .fetch_one(&admin_pool)
16154 .await
16155 .unwrap();
16156
16157 assert!(
16158 error
16159 .to_string()
16160 .contains("is not a complete EIP-2718 envelope"),
16161 "was: {error}"
16162 );
16163 assert_eq!(recovery.intent_id, intent.id);
16164 assert_eq!(recovery.nonce, 7);
16165 assert_eq!(recovery.purpose, TransactionPurpose::Swap);
16166 assert_eq!(status, expected_status);
16167 assert!(!acknowledgement_emitted);
16168 assert!(active);
16169 assert_eq!(transitions_after, transitions_before);
16170 assert!(collect_order_events(&mut receiver).is_empty());
16171 assert!(
16172 state
16173 .recorded_requests()
16174 .iter()
16175 .all(|request| request["method"] != "eth_sendRawTransaction")
16176 );
16177
16178 drop_execution_schema(&admin_pool, &schema).await;
16179 }
16180
16181 #[tokio::test]
16182 async fn restart_rebroadcasts_only_durably_authorized_bytes() {
16183 let Some((admin_pool, schema, first_client, _)) =
16184 execution_client_with_database("execution_broadcast_restart_test", ready_rpc_state())
16185 .await
16186 else {
16187 return;
16188 };
16189 let database = first_client.cache.database.as_ref().unwrap().clone();
16190 let (_, _, raw_tx) =
16191 persist_test_wrap_broadcast(&database, first_client.payload_keys.as_deref()).await;
16192 let payload_keys = first_client.payload_keys.clone();
16193 drop(first_client);
16194
16195 let restart_state = execution_rpc_state()
16196 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
16197 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16198 .with_response("eth_call", CALL_EMPTY)
16199 .with_send_raw_transaction_echo();
16200 let addr = start_mock_rpc_server(restart_state.clone()).await;
16201 let mut restarted = test_client(format!("http://{addr}"));
16202 restarted.cache.database = Some(database);
16203 restarted.payload_keys = payload_keys;
16204 restarted.signer = Some(Arc::new(
16205 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16206 ));
16207
16208 restarted.reconcile_unresolved_execution().await.unwrap();
16209
16210 let broadcasts = restart_state
16211 .recorded_requests()
16212 .into_iter()
16213 .filter(|request| request["method"] == "eth_sendRawTransaction")
16214 .collect::<Vec<_>>();
16215 assert_eq!(broadcasts.len(), 1);
16216 assert_eq!(broadcasts[0]["params"][0], hex::encode_prefixed(&raw_tx));
16217 assert_eq!(
16218 execution_intent_markers(&admin_pool, &schema).await,
16219 vec![("wrap".into(), "dropped".into(), false, true)]
16220 );
16221
16222 drop_execution_schema(&admin_pool, &schema).await;
16223 }
16224
16225 #[tokio::test]
16226 async fn restart_suppresses_rebroadcast_when_canonical_nonce_advanced() {
16227 let Some((admin_pool, schema, first_client, _)) = execution_client_with_database(
16228 "execution_rebroadcast_nonce_advanced_test",
16229 ready_rpc_state(),
16230 )
16231 .await
16232 else {
16233 return;
16234 };
16235 let database = first_client.cache.database.as_ref().unwrap().clone();
16236 let (intent, _, _) =
16237 persist_test_wrap_broadcast(&database, first_client.payload_keys.as_deref()).await;
16238 let payload_keys = first_client.payload_keys.clone();
16239 drop(first_client);
16240
16241 let mut empty_block: serde_json::Value =
16242 serde_json::from_str(&replacement_head_block(B256::from([0x44; 32]))).unwrap();
16243 empty_block["result"]["transactions"] = serde_json::json!([]);
16244 let empty_block = empty_block.to_string();
16245 let restart_state = execution_rpc_state()
16246 .with_response("eth_getTransactionCount", TRANSACTION_COUNT_NEXT)
16247 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16248 .with_response("eth_call", CALL_EMPTY)
16249 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d40", &empty_block)
16250 .with_send_raw_transaction_echo();
16251 let addr = start_mock_rpc_server(restart_state.clone()).await;
16252 let mut restarted = test_client(format!("http://{addr}"));
16253 restarted.cache.database = Some(database);
16254 restarted.payload_keys = payload_keys;
16255 restarted.signer = Some(Arc::new(
16256 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16257 ));
16258
16259 let error = restarted
16260 .reconcile_unresolved_execution()
16261 .await
16262 .unwrap_err();
16263
16264 let requests = restart_state.recorded_requests();
16265 assert!(
16266 requests
16267 .iter()
16268 .all(|request| request["method"] != "eth_sendRawTransaction")
16269 );
16270 assert!(requests.iter().all(|request| {
16271 request["method"] != "eth_call" || request["params"][0]["data"] != "0xd0e30db0"
16272 }));
16273 let decision_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16274 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16275 WHERE intent_id = $1 AND decision_class = 'rebroadcast'"
16276 )))
16277 .bind(intent.id)
16278 .fetch_one(&admin_pool)
16279 .await
16280 .unwrap();
16281 assert!(
16282 error
16283 .to_string()
16284 .contains("without an authenticated signer transaction"),
16285 "was: {error}"
16286 );
16287 assert_eq!(decision_count, 6);
16288 let replacement_decision_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16289 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16290 WHERE intent_id = $1 AND decision_class = 'replacement_scan'"
16291 )))
16292 .bind(intent.id)
16293 .fetch_one(&admin_pool)
16294 .await
16295 .unwrap();
16296 assert_eq!(replacement_decision_count, 2);
16297 let nonce_state = sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!(
16298 "SELECT next_canonical_nonce, revision FROM {schema}.execution_verification_nonce"
16299 )))
16300 .fetch_one(&admin_pool)
16301 .await
16302 .unwrap();
16303 assert_eq!(nonce_state, (7, 0));
16304
16305 drop_execution_schema(&admin_pool, &schema).await;
16306 }
16307
16308 #[tokio::test]
16309 async fn restart_suppresses_rebroadcast_when_receipt_exists() {
16310 let Some((admin_pool, schema, first_client, _)) = execution_client_with_database(
16311 "execution_rebroadcast_receipt_present_test",
16312 ready_rpc_state(),
16313 )
16314 .await
16315 else {
16316 return;
16317 };
16318 let database = first_client.cache.database.as_ref().unwrap().clone();
16319 let (intent, _, _) =
16320 persist_test_wrap_broadcast(&database, first_client.payload_keys.as_deref()).await;
16321 let payload_keys = first_client.payload_keys.clone();
16322 drop(first_client);
16323
16324 let restart_state = execution_rpc_state()
16325 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
16326 .with_response("eth_getTransactionReceipt", RECEIPT_SUCCESS)
16327 .with_response("eth_call", CALL_EMPTY)
16328 .with_send_raw_transaction_echo();
16329 let addr = start_mock_rpc_server(restart_state.clone()).await;
16330 let mut restarted = test_client(format!("http://{addr}"));
16331 restarted.cache.database = Some(database);
16332 restarted.payload_keys = payload_keys;
16333 restarted.signer = Some(Arc::new(
16334 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16335 ));
16336
16337 let error = restarted
16338 .reconcile_unresolved_execution()
16339 .await
16340 .unwrap_err();
16341
16342 assert!(
16343 error
16344 .to_string()
16345 .contains("finalized transaction verification is locally invalid"),
16346 "was: {error}"
16347 );
16348 let requests = restart_state.recorded_requests();
16349 assert!(
16350 requests
16351 .iter()
16352 .all(|request| request["method"] != "eth_sendRawTransaction")
16353 );
16354 assert!(requests.iter().all(|request| {
16355 request["method"] != "eth_call" || request["params"][0]["data"] != "0xd0e30db0"
16356 }));
16357 let decision_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16358 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16359 WHERE intent_id = $1 AND decision_class = 'rebroadcast'"
16360 )))
16361 .bind(intent.id)
16362 .fetch_one(&admin_pool)
16363 .await
16364 .unwrap();
16365 assert_eq!(decision_count, 6);
16366
16367 drop_execution_schema(&admin_pool, &schema).await;
16368 }
16369
16370 #[rstest]
16371 #[case("execution_rebroadcast_false_test", CALL_ZERO)]
16372 #[case("execution_rebroadcast_revert_test", CALL_REVERTED)]
16373 #[tokio::test]
16374 async fn restart_suppresses_rebroadcast_when_simulation_denies(
16375 #[case] test_name: &str,
16376 #[case] simulation_response: &str,
16377 ) {
16378 let Some((admin_pool, schema, first_client, _)) =
16379 execution_client_with_database(test_name, ready_rpc_state()).await
16380 else {
16381 return;
16382 };
16383 let database = first_client.cache.database.as_ref().unwrap().clone();
16384 let (intent, _, _) =
16385 persist_test_wrap_broadcast(&database, first_client.payload_keys.as_deref()).await;
16386 let payload_keys = first_client.payload_keys.clone();
16387 drop(first_client);
16388
16389 let restart_state = execution_rpc_state()
16390 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
16391 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16392 .with_response("eth_call", simulation_response)
16393 .with_send_raw_transaction_echo();
16394 let addr = start_mock_rpc_server(restart_state.clone()).await;
16395 let mut restarted = test_client(format!("http://{addr}"));
16396 restarted.cache.database = Some(database);
16397 restarted.payload_keys = payload_keys;
16398 restarted.signer = Some(Arc::new(
16399 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16400 ));
16401
16402 restarted.reconcile_unresolved_execution().await.unwrap();
16403
16404 let requests = restart_state.recorded_requests();
16405 assert!(
16406 requests
16407 .iter()
16408 .all(|request| request["method"] != "eth_sendRawTransaction")
16409 );
16410 let simulation_calls = requests
16411 .iter()
16412 .filter(|request| {
16413 request["method"] == "eth_call"
16414 && request["params"][0]["data"] == "0xd0e30db0"
16415 && request["params"][1] == FIXTURE_BLOCK_PARAM
16416 })
16417 .count();
16418 assert_eq!(simulation_calls, 3);
16419 let decision_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16420 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16421 WHERE intent_id = $1 AND decision_class = 'rebroadcast'"
16422 )))
16423 .bind(intent.id)
16424 .fetch_one(&admin_pool)
16425 .await
16426 .unwrap();
16427 assert_eq!(decision_count, 7);
16428
16429 drop_execution_schema(&admin_pool, &schema).await;
16430 }
16431
16432 #[tokio::test]
16433 async fn restart_wrap_identity_mismatch_keeps_signer_ownership() {
16434 let initial_state = broadcast_rpc_state()
16435 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16436 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16437 let Some((admin_pool, schema, mut first_client, _)) =
16438 execution_client_with_database("execution_restart_mismatch_test", initial_state).await
16439 else {
16440 return;
16441 };
16442 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
16443 let error = first_client
16444 .wrap(U256::from(1_000_000_000_000_000_u64))
16445 .await
16446 .unwrap_err();
16447 assert!(
16448 error.to_string().contains("Timed out awaiting finality"),
16449 "was: {error}"
16450 );
16451 let database = first_client.cache.database.as_ref().unwrap().clone();
16452 let payload_keys = first_client.payload_keys.clone();
16453 drop(first_client);
16454
16455 let mismatched_block = serde_json::json!({
16457 "jsonrpc": "2.0",
16458 "id": 1,
16459 "result": {
16460 "number": "0x1cf0d41",
16461 "hash": "0x2222222222222222222222222222222222222222222222222222222222222222",
16462 "parentHash": FIXTURE_BLOCK_HASH,
16463 "timestamp": "0x69044a21",
16464 "baseFeePerGas": "0x5f5e100",
16465 "transactions": [{
16466 "hash": expected_hash.to_string(),
16467 "from": WALLET,
16468 "nonce": "0x7",
16469 "chainId": "0xa4b1",
16470 "type": "0x2",
16471 "to": WETH,
16472 "input": "0xd0e30db0",
16473 "value": "0x0",
16474 "gas": "0x130b0",
16475 "maxFeePerGas": "0x7bfa480",
16476 "maxPriorityFeePerGas": "0x989680"
16477 }]
16478 }
16479 })
16480 .to_string();
16481 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
16482 let restart_state = with_finalized_identity(
16483 execution_rpc_state()
16484 .with_response("eth_getTransactionReceipt", &receipt)
16485 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &mismatched_block),
16486 &mismatched_block,
16487 &receipt,
16488 );
16489 let addr = start_mock_rpc_server(restart_state.clone()).await;
16490 let mut restarted = test_client(format!("http://{addr}"));
16491 restarted.cache.database = Some(database);
16492 restarted.payload_keys = payload_keys;
16493 restarted.signer = Some(Arc::new(
16494 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16495 ));
16496
16497 let error = restarted
16498 .reconcile_unresolved_execution()
16499 .await
16500 .unwrap_err();
16501
16502 assert!(
16503 error
16504 .to_string()
16505 .contains("finalized transaction identity mismatch"),
16506 "was: {error}"
16507 );
16508 let in_flight = awaiting_in_flight(&restarted);
16509 assert_eq!(in_flight.nonce, 7);
16510 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
16511 assert_eq!(in_flight.tx_hash, expected_hash);
16512 let requests = restart_state.recorded_requests();
16513 assert!(
16514 requests.iter().all(|request| {
16515 request["method"] != "eth_call"
16516 || !request["params"][0]["data"]
16517 .as_str()
16518 .is_some_and(|data| data.starts_with(BALANCE_OF_SELECTOR))
16519 }),
16520 "the postcondition must not run when call identity is unproven"
16521 );
16522 assert!(
16523 requests
16524 .iter()
16525 .all(|request| request["method"] != "eth_sendRawTransaction")
16526 );
16527 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
16528 "SELECT status, active FROM {schema}.execution_intent"
16529 )))
16530 .fetch_one(&admin_pool)
16531 .await
16532 .unwrap();
16533 assert_eq!(status, "dropped");
16534 assert!(active);
16535
16536 let database = restarted.cache.database.as_ref().unwrap().clone();
16537 let payload_keys = restarted.payload_keys.clone();
16538 drop(restarted);
16539 let mut second = test_client(format!("http://{addr}"));
16540 second.cache.database = Some(database);
16541 second.payload_keys = payload_keys;
16542 second.signer = Some(Arc::new(
16543 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16544 ));
16545 let error = second.reconcile_unresolved_execution().await.unwrap_err();
16546 assert!(
16547 error
16548 .to_string()
16549 .contains("finalized transaction identity mismatch"),
16550 "was: {error}"
16551 );
16552 let active: bool = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16553 "SELECT active FROM {schema}.execution_intent"
16554 )))
16555 .fetch_one(&admin_pool)
16556 .await
16557 .unwrap();
16558 assert!(active);
16559
16560 drop_execution_schema(&admin_pool, &schema).await;
16561 }
16562
16563 #[tokio::test]
16564 async fn restart_wrap_postcondition_failure_keeps_signer_ownership() {
16565 let initial_state = broadcast_rpc_state()
16566 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16567 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16568 let Some((admin_pool, schema, mut first_client, _)) =
16569 execution_client_with_database("execution_restart_postcondition_test", initial_state)
16570 .await
16571 else {
16572 return;
16573 };
16574 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
16575 let error = first_client
16576 .wrap(U256::from(1_000_000_000_000_000_u64))
16577 .await
16578 .unwrap_err();
16579 assert!(
16580 error.to_string().contains("Timed out awaiting finality"),
16581 "was: {error}"
16582 );
16583 let database = first_client.cache.database.as_ref().unwrap().clone();
16584 let payload_keys = first_client.payload_keys.clone();
16585 drop(first_client);
16586
16587 let failing_wrap_state = || {
16589 let block = finalized_wrap_block(expected_hash);
16590 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
16591 with_finalized_identity(
16592 execution_rpc_state()
16593 .with_response("eth_getTransactionReceipt", &receipt)
16594 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
16595 .with_response_sequence("eth_call", &[CALL_BALANCE; 6]),
16596 &block,
16597 &receipt,
16598 )
16599 };
16600 let restart_state = failing_wrap_state();
16601 let addr = start_mock_rpc_server(restart_state.clone()).await;
16602 let mut restarted = test_client(format!("http://{addr}"));
16603 restarted.cache.database = Some(database);
16604 restarted.payload_keys = payload_keys;
16605 restarted.signer = Some(Arc::new(
16606 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16607 ));
16608
16609 let error = restarted
16610 .reconcile_unresolved_execution()
16611 .await
16612 .unwrap_err();
16613
16614 assert!(
16615 error.to_string().contains("did not increase by"),
16616 "was: {error}"
16617 );
16618 let in_flight = awaiting_in_flight(&restarted);
16619 assert_eq!(in_flight.nonce, 7);
16620 assert_eq!(in_flight.purpose, TransactionPurpose::Wrap);
16621 assert!(
16622 restart_state
16623 .recorded_requests()
16624 .iter()
16625 .all(|request| request["method"] != "eth_sendRawTransaction")
16626 );
16627 assert_eq!(
16628 execution_intent_markers(&admin_pool, &schema).await,
16629 vec![("wrap".into(), "dropped".into(), false, true)]
16630 );
16631
16632 let addr = start_mock_rpc_server(failing_wrap_state()).await;
16633 let error = later_reconnect(restarted, format!("http://{addr}")).await;
16634 assert!(
16635 error.to_string().contains("did not increase by"),
16636 "was: {error}"
16637 );
16638 assert_eq!(
16639 execution_intent_markers(&admin_pool, &schema).await,
16640 vec![("wrap".into(), "dropped".into(), false, true)]
16641 );
16642
16643 drop_execution_schema(&admin_pool, &schema).await;
16644 }
16645
16646 #[tokio::test]
16647 async fn restart_wrap_revert_marks_terminal_and_releases() {
16648 let initial_state = broadcast_rpc_state()
16649 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16650 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16651 let Some((admin_pool, schema, mut first_client, _)) =
16652 execution_client_with_database("execution_restart_wrap_revert_test", initial_state)
16653 .await
16654 else {
16655 return;
16656 };
16657 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
16658 let error = first_client
16659 .wrap(U256::from(1_000_000_000_000_000_u64))
16660 .await
16661 .unwrap_err();
16662 assert!(
16663 error.to_string().contains("Timed out awaiting finality"),
16664 "was: {error}"
16665 );
16666 let database = first_client.cache.database.as_ref().unwrap().clone();
16667 let payload_keys = first_client.payload_keys.clone();
16668 drop(first_client);
16669
16670 let block = finalized_wrap_block(expected_hash);
16671 let receipt = receipt_with_transaction_hash(RECEIPT_REVERTED, expected_hash);
16672 let restart_state = with_finalized_identity(
16673 execution_rpc_state()
16674 .with_response("eth_getTransactionReceipt", &receipt)
16675 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block),
16676 &block,
16677 &receipt,
16678 );
16679 let addr = start_mock_rpc_server(restart_state.clone()).await;
16680 let mut restarted = test_client(format!("http://{addr}"));
16681 restarted.cache.database = Some(database);
16682 restarted.payload_keys = payload_keys;
16683 restarted.signer = Some(Arc::new(
16684 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16685 ));
16686
16687 restarted.reconcile_unresolved_execution().await.unwrap();
16688
16689 assert!(restarted.in_flight.lock().is_none());
16690 assert!(
16691 restart_state
16692 .recorded_requests()
16693 .iter()
16694 .all(|request| request["method"] != "eth_sendRawTransaction")
16695 );
16696 assert_eq!(
16697 execution_intent_markers(&admin_pool, &schema).await,
16698 vec![("wrap".into(), "reverted".into(), true, false)]
16699 );
16700
16701 drop_execution_schema(&admin_pool, &schema).await;
16702 }
16703
16704 #[tokio::test]
16705 async fn restart_quarantines_unretained_same_nonce_wrap_replacement() {
16706 let initial_state = broadcast_rpc_state()
16707 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16708 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE);
16709 let Some((admin_pool, schema, mut first_client, _)) =
16710 execution_client_with_database("execution_restart_replacement_test", initial_state)
16711 .await
16712 else {
16713 return;
16714 };
16715 let original_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000_u64)).await;
16716 let error = first_client
16717 .wrap(U256::from(1_000_000_000_000_000_u64))
16718 .await
16719 .unwrap_err();
16720 assert!(
16721 error.to_string().contains("Timed out awaiting finality"),
16722 "was: {error}"
16723 );
16724 let database = first_client.cache.database.as_ref().unwrap().clone();
16725 let payload_keys = first_client.payload_keys.clone();
16726 drop(first_client);
16727
16728 let replacement_hash = B256::from([0x44; 32]);
16730 let restart_state = execution_rpc_state()
16731 .with_response("eth_getTransactionCount", TRANSACTION_COUNT_NEXT)
16732 .with_response_sequence("eth_call", &[CALL_BALANCE, CALL_BALANCE_AFTER_WRAP])
16733 .with_response_sequence(
16734 "eth_getTransactionReceipt",
16735 &[RECEIPT_NULL, RECEIPT_NULL, RECEIPT_NULL],
16736 )
16737 .with_parameter_response(
16738 "eth_getBlockByNumber",
16739 "0x1cf0d40",
16740 &replacement_head_block(replacement_hash),
16741 )
16742 .with_parameter_response(
16743 "eth_getBlockByNumber",
16744 "0x1cf0d41",
16745 &finalized_wrap_block(replacement_hash),
16746 );
16747 let addr = start_mock_rpc_server(restart_state.clone()).await;
16748 let mut restarted = test_client(format!("http://{addr}"));
16749 restarted.cache.database = Some(database);
16750 restarted.payload_keys = payload_keys;
16751 restarted.signer = Some(Arc::new(
16752 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16753 ));
16754 restarted.transaction_limits.receipt_timeout_secs = 2;
16755
16756 let error = restarted
16757 .reconcile_unresolved_execution()
16758 .await
16759 .unwrap_err();
16760
16761 let hashes: Vec<(String, String, bool)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
16762 "SELECT transaction_hash, status, current FROM \
16763 {schema}.execution_transaction_hash ORDER BY id"
16764 )))
16765 .fetch_all(&admin_pool)
16766 .await
16767 .unwrap();
16768 let requests = restart_state.recorded_requests();
16769
16770 assert!(
16771 error
16772 .to_string()
16773 .contains("has no authenticated retained payload"),
16774 "was: {error}"
16775 );
16776 assert_eq!(
16777 hashes,
16778 [(original_hash.to_string(), "dropped".to_string(), true)]
16779 );
16780 assert!(restarted.in_flight.lock().is_some());
16781 assert_eq!(
16782 execution_intent_markers(&admin_pool, &schema).await,
16783 vec![("wrap".into(), "dropped".into(), false, true)]
16784 );
16785 assert_eq!(
16786 requests
16787 .iter()
16788 .filter(|request| request["method"] == "eth_getTransactionReceipt")
16789 .count(),
16790 3
16791 );
16792 assert!(requests.iter().all(|request| {
16793 request["method"] != "eth_call" || request["params"][0]["data"] != "0xd0e30db0"
16794 }));
16795 assert!(
16796 requests
16797 .iter()
16798 .all(|request| request["method"] != "eth_sendRawTransaction")
16799 );
16800
16801 drop_execution_schema(&admin_pool, &schema).await;
16802 }
16803
16804 #[tokio::test]
16805 async fn replacement_scan_accepts_only_an_authenticated_retained_payload() {
16806 let expected_hash = expected_wrap_tx_hash(U256::from(1_u64)).await;
16807 let mut replacement_block: serde_json::Value =
16808 serde_json::from_str(&replacement_head_block(expected_hash)).unwrap();
16809 replacement_block["result"]["transactions"][1]["value"] = serde_json::json!("0x1");
16810 let replacement_block = replacement_block.to_string();
16811 let state = execution_rpc_state().with_parameter_response(
16812 "eth_getBlockByNumber",
16813 "0x1cf0d40",
16814 &replacement_block,
16815 );
16816 let Some((admin_pool, schema, client, _)) =
16817 execution_client_with_database("replacement_scan_authenticated", state).await
16818 else {
16819 return;
16820 };
16821 let database = client.cache.database.as_ref().unwrap();
16822 let (intent, persisted_hash, persisted_raw) =
16823 persist_test_wrap_broadcast(database, client.payload_keys.as_deref()).await;
16824 assert_eq!(persisted_hash, expected_hash);
16825 let authenticated_payloads = HashMap::from([(expected_hash, persisted_raw.clone())]);
16826 let head = VerifiedBlockHeader {
16827 number: FIXTURE_BLOCK,
16828 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
16829 parent_hash: B256::from_str(
16830 "0x0000000000000000000000000000000000000000000000000000000000000001",
16831 )
16832 .unwrap(),
16833 timestamp: FIXTURE_BLOCK_TIMESTAMP,
16834 base_fee_per_gas: Some(100_000_000),
16835 };
16836
16837 let matched = client
16838 .transaction_executor()
16839 .unwrap()
16840 .scan_canonical_replacement(&intent, 7, head, &authenticated_payloads)
16841 .await
16842 .unwrap()
16843 .unwrap();
16844
16845 assert_eq!(matched, (expected_hash, persisted_raw));
16846 let cursor: (i64, String) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
16847 "SELECT finalized_cursor_number, finalized_cursor_hash \
16848 FROM {schema}.execution_replacement_scan WHERE intent_id = $1"
16849 )))
16850 .bind(intent.id)
16851 .fetch_one(&admin_pool)
16852 .await
16853 .unwrap();
16854 assert_eq!(
16855 cursor,
16856 (FIXTURE_BLOCK as i64, FIXTURE_BLOCK_HASH.to_string())
16857 );
16858 let evidence_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
16859 "SELECT COUNT(*) FROM {schema}.execution_verification_decision \
16860 WHERE intent_id = $1 AND decision_class = 'replacement_scan'"
16861 )))
16862 .bind(intent.id)
16863 .fetch_one(&admin_pool)
16864 .await
16865 .unwrap();
16866 assert_eq!(evidence_count, 2);
16867
16868 drop_execution_schema(&admin_pool, &schema).await;
16869 }
16870
16871 #[tokio::test]
16872 async fn restart_reconciles_finalized_approve_after_validation() {
16873 let initial_state = broadcast_rpc_state()
16874 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16875 .with_response("eth_call", CALL_BOOL_TRUE)
16876 .with_call_response_sequence(ALLOWANCE_SELECTOR, &[CALL_ZERO; 3])
16877 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO);
16878 let Some((admin_pool, schema, mut first_client, _)) =
16879 execution_client_with_database("execution_restart_approve_test", initial_state).await
16880 else {
16881 return;
16882 };
16883 let expected_hash = expected_approve_tx_hash(U256::from(1_000u64)).await;
16884 let error = first_client
16885 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
16886 .await
16887 .unwrap_err();
16888 assert!(
16889 error.to_string().contains("Timed out awaiting finality"),
16890 "was: {error}"
16891 );
16892 let database = first_client.cache.database.as_ref().unwrap().clone();
16893 let payload_keys = first_client.payload_keys.clone();
16894 drop(first_client);
16895
16896 let block = finalized_approve_block(expected_hash, U256::from(1_000u64));
16897 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
16898 let restart_state = with_finalized_identity(
16899 execution_rpc_state()
16900 .with_response("eth_getTransactionReceipt", &receipt)
16901 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
16902 .with_call_response(ALLOWANCE_SELECTOR, CALL_ALLOWANCE_1000),
16903 &block,
16904 &receipt,
16905 );
16906 let addr = start_mock_rpc_server(restart_state.clone()).await;
16907 let mut restarted = test_client(format!("http://{addr}"));
16908 restarted.cache.database = Some(database);
16909 restarted.payload_keys = payload_keys;
16910 restarted.signer = Some(Arc::new(
16911 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16912 ));
16913
16914 restarted.reconcile_unresolved_execution().await.unwrap();
16915
16916 let record = restarted
16917 .cache
16918 .get_execution_transaction(42161, &expected_hash.to_string())
16919 .await
16920 .unwrap()
16921 .unwrap();
16922 let requests = restart_state.recorded_requests();
16923 assert_eq!(record.status, "finalized");
16924 assert_eq!(record.purpose, "approve");
16925 assert!(restarted.in_flight.lock().is_none());
16926 assert_eq!(
16927 execution_intent_markers(&admin_pool, &schema).await,
16928 vec![("approve".into(), "finalized".into(), true, false)]
16929 );
16930 assert_eq!(
16931 requests
16932 .iter()
16933 .filter(|request| {
16934 request["method"] == "eth_call"
16935 && request["params"][0]["data"]
16936 .as_str()
16937 .is_some_and(|data| data.starts_with(ALLOWANCE_SELECTOR))
16938 })
16939 .count(),
16940 3
16941 );
16942 assert_eq!(
16943 requests
16944 .iter()
16945 .filter(|request| request["method"] == "eth_sendRawTransaction")
16946 .count(),
16947 0
16948 );
16949
16950 drop_execution_schema(&admin_pool, &schema).await;
16951 }
16952
16953 #[tokio::test]
16954 async fn restart_approve_postcondition_failure_keeps_signer_ownership() {
16955 let initial_state = broadcast_rpc_state()
16956 .with_response("eth_getTransactionReceipt", RECEIPT_NULL)
16957 .with_response("eth_call", CALL_BOOL_TRUE)
16958 .with_call_response_sequence(ALLOWANCE_SELECTOR, &[CALL_ZERO; 3])
16959 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO);
16960 let Some((admin_pool, schema, mut first_client, _)) =
16961 execution_client_with_database("execution_restart_approve_post_test", initial_state)
16962 .await
16963 else {
16964 return;
16965 };
16966 let expected_hash = expected_approve_tx_hash(U256::from(1_000u64)).await;
16967 let error = first_client
16968 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
16969 .await
16970 .unwrap_err();
16971 assert!(
16972 error.to_string().contains("Timed out awaiting finality"),
16973 "was: {error}"
16974 );
16975 let database = first_client.cache.database.as_ref().unwrap().clone();
16976 let payload_keys = first_client.payload_keys.clone();
16977 drop(first_client);
16978
16979 let block = finalized_approve_block(expected_hash, U256::from(1_000u64));
16981 let receipt = receipt_with_transaction_hash(RECEIPT_SUCCESS, expected_hash);
16982 let restart_state = with_finalized_identity(
16983 execution_rpc_state()
16984 .with_response("eth_getTransactionReceipt", &receipt)
16985 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d41", &block)
16986 .with_call_response(ALLOWANCE_SELECTOR, CALL_ZERO),
16987 &block,
16988 &receipt,
16989 );
16990 let addr = start_mock_rpc_server(restart_state.clone()).await;
16991 let mut restarted = test_client(format!("http://{addr}"));
16992 restarted.cache.database = Some(database);
16993 restarted.payload_keys = payload_keys;
16994 restarted.signer = Some(Arc::new(
16995 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
16996 ));
16997
16998 let error = restarted
16999 .reconcile_unresolved_execution()
17000 .await
17001 .unwrap_err();
17002
17003 assert!(
17004 error
17005 .to_string()
17006 .contains("does not equal the requested amount"),
17007 "was: {error}"
17008 );
17009 let in_flight = awaiting_in_flight(&restarted);
17010 assert_eq!(in_flight.nonce, 7);
17011 assert_eq!(in_flight.purpose, TransactionPurpose::Approve);
17012 assert!(
17013 restart_state
17014 .recorded_requests()
17015 .iter()
17016 .all(|request| request["method"] != "eth_sendRawTransaction")
17017 );
17018 assert_eq!(
17019 execution_intent_markers(&admin_pool, &schema).await,
17020 vec![("approve".into(), "dropped".into(), false, true)]
17021 );
17022
17023 let error = later_reconnect(restarted, format!("http://{addr}")).await;
17024 assert!(
17025 error
17026 .to_string()
17027 .contains("does not equal the requested amount"),
17028 "was: {error}"
17029 );
17030 assert_eq!(
17031 execution_intent_markers(&admin_pool, &schema).await,
17032 vec![("approve".into(), "dropped".into(), false, true)]
17033 );
17034
17035 drop_execution_schema(&admin_pool, &schema).await;
17036 }
17037
17038 #[tokio::test]
17039 async fn disappearing_unfinalized_receipt_drops_without_committing_inclusion() {
17040 let state = execution_rpc_state()
17041 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
17042 .with_response("eth_estimateGas", ESTIMATE_GAS)
17043 .with_response("eth_call", CALL_BALANCE)
17044 .with_response_sequence(
17045 "eth_getTransactionReceipt",
17046 &[
17047 RECEIPT_SUCCESS,
17048 RECEIPT_SUCCESS,
17049 RECEIPT_SUCCESS,
17050 RECEIPT_NULL,
17051 RECEIPT_NULL,
17052 RECEIPT_NULL,
17053 ],
17054 )
17055 .with_parameter_response("eth_getBlockByNumber", "finalized", BLOCK_BY_NUMBER)
17056 .with_send_raw_transaction_echo();
17057 let Some((admin_pool, schema, mut client, _)) =
17058 execution_client_with_database("execution_receipt_disappeared_test", state).await
17059 else {
17060 return;
17061 };
17062 client.transaction_limits.receipt_timeout_secs = 2;
17063
17064 let error = client
17065 .wrap(U256::from(1_000_000_000_000_000_u64))
17066 .await
17067 .unwrap_err();
17068 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17069 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
17070 )))
17071 .fetch_all(&admin_pool)
17072 .await
17073 .unwrap();
17074
17075 assert!(
17076 error.to_string().contains("Timed out awaiting finality"),
17077 "was: {error}"
17078 );
17079 assert_eq!(transitions, ["prepared", "signed", "broadcast", "dropped"]);
17080 assert!(client.in_flight.lock().is_some());
17081
17082 drop_execution_schema(&admin_pool, &schema).await;
17083 }
17084
17085 #[tokio::test]
17086 async fn changed_unfinalized_block_drops_without_committing_inclusion() {
17087 let changed_block = serde_json::json!({
17088 "jsonrpc": "2.0",
17089 "id": 1,
17090 "result": {
17091 "number": "0x1cf0d41",
17092 "hash": "0x4444444444444444444444444444444444444444444444444444444444444444",
17093 "parentHash": FIXTURE_BLOCK_HASH,
17094 "timestamp": "0x69044a21",
17095 "baseFeePerGas": "0x5f5e100",
17096 "transactions": []
17097 }
17098 })
17099 .to_string();
17100 let state = execution_rpc_state()
17101 .with_response("eth_getTransactionCount", TRANSACTION_COUNT)
17102 .with_response("eth_estimateGas", ESTIMATE_GAS)
17103 .with_response("eth_call", CALL_BALANCE)
17104 .with_response_sequence(
17105 "eth_getTransactionReceipt",
17106 &[
17107 RECEIPT_SUCCESS,
17108 RECEIPT_SUCCESS,
17109 RECEIPT_SUCCESS,
17110 RECEIPT_SUCCESS,
17111 RECEIPT_SUCCESS,
17112 RECEIPT_SUCCESS,
17113 ],
17114 )
17115 .with_parameter_response_sequence(
17116 "eth_getBlockByNumber",
17117 "0x1cf0d41",
17118 &[
17119 BLOCK_CANONICAL,
17120 BLOCK_CANONICAL,
17121 BLOCK_CANONICAL,
17122 &changed_block,
17123 &changed_block,
17124 &changed_block,
17125 ],
17126 )
17127 .with_parameter_response("eth_getBlockByNumber", "finalized", BLOCK_BY_NUMBER)
17128 .with_send_raw_transaction_echo();
17129 let Some((admin_pool, schema, mut client, _)) =
17130 execution_client_with_database("execution_reorg_test", state).await
17131 else {
17132 return;
17133 };
17134 client.transaction_limits.receipt_timeout_secs = 2;
17135
17136 let error = client
17137 .wrap(U256::from(1_000_000_000_000_000_u64))
17138 .await
17139 .unwrap_err();
17140 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17141 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
17142 )))
17143 .fetch_all(&admin_pool)
17144 .await
17145 .unwrap();
17146
17147 assert!(
17148 error.to_string().contains("Timed out awaiting finality"),
17149 "was: {error}"
17150 );
17151 assert_eq!(transitions, ["prepared", "signed", "broadcast", "dropped"]);
17152 assert!(client.in_flight.lock().is_some());
17153
17154 drop_execution_schema(&admin_pool, &schema).await;
17155 }
17156
17157 #[tokio::test]
17158 async fn pre_sign_pending_nonce_drift_blocks_wrap_before_signature() {
17159 let state = execution_rpc_state()
17160 .with_response_sequence(
17161 "eth_getTransactionCount",
17162 &[
17163 TRANSACTION_COUNT,
17164 TRANSACTION_COUNT,
17165 TRANSACTION_COUNT,
17166 TRANSACTION_COUNT_NEXT,
17167 TRANSACTION_COUNT_NEXT,
17168 TRANSACTION_COUNT_NEXT,
17169 ],
17170 )
17171 .with_response("eth_estimateGas", ESTIMATE_GAS)
17172 .with_call_response(BALANCE_OF_SELECTOR, CALL_BALANCE)
17173 .with_send_raw_transaction_echo();
17174 let Some((admin_pool, schema, mut client, state)) =
17175 execution_client_with_database("pre_sign_pending_nonce_drift", state).await
17176 else {
17177 return;
17178 };
17179
17180 let error = client
17181 .wrap(U256::from(1_000_000_000_000_000_u64))
17182 .await
17183 .unwrap_err();
17184 let signed_count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17185 "SELECT COUNT(*) FROM {schema}.execution_transaction_hash"
17186 )))
17187 .fetch_one(&admin_pool)
17188 .await
17189 .unwrap();
17190
17191 assert!(
17192 error
17193 .to_string()
17194 .contains("Pending nonce does not match the verified canonical nonce"),
17195 "was: {error}"
17196 );
17197 assert_eq!(signed_count, 0);
17198 assert!(
17199 state
17200 .recorded_requests()
17201 .iter()
17202 .all(|request| request["method"] != "eth_sendRawTransaction")
17203 );
17204 assert!(client.in_flight.lock().is_none());
17205
17206 drop_execution_schema(&admin_pool, &schema).await;
17207 }
17208
17209 #[tokio::test]
17210 async fn unknown_same_nonce_swap_replacement_emits_no_rejection() {
17211 let replacement_hash = B256::from([0x44; 32]);
17212 let replacement_block = replacement_head_block(replacement_hash);
17213 let state = execution_rpc_state()
17214 .with_parameter_response("eth_getBlockByNumber", "0x1cf0d40", &replacement_block)
17215 .with_send_raw_transaction_echo();
17216 let Some((admin_pool, schema, mut client, _, _)) =
17217 swap_client_with_database("unknown_same_nonce_swap_replacement", state).await
17218 else {
17219 return;
17220 };
17221 let mut receiver = start_with_events(&mut client);
17222 let database = client.cache.database.as_ref().unwrap();
17223 let (intent, original_hash, original_payload) =
17224 persist_test_swap_broadcast(database, client.payload_keys.as_deref()).await;
17225 let authenticated_payloads = HashMap::from([(original_hash, original_payload)]);
17226 let head = VerifiedBlockHeader {
17227 number: FIXTURE_BLOCK,
17228 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
17229 parent_hash: B256::from_str(
17230 "0x0000000000000000000000000000000000000000000000000000000000000001",
17231 )
17232 .unwrap(),
17233 timestamp: FIXTURE_BLOCK_TIMESTAMP,
17234 base_fee_per_gas: Some(100_000_000),
17235 };
17236
17237 let error = client
17238 .transaction_executor()
17239 .unwrap()
17240 .scan_canonical_replacement(&intent, 7, head, &authenticated_payloads)
17241 .await
17242 .unwrap_err();
17243 let events = collect_order_events(&mut receiver);
17244 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
17245 "SELECT status, active FROM {schema}.execution_intent"
17246 )))
17247 .fetch_one(&admin_pool)
17248 .await
17249 .unwrap();
17250
17251 assert!(
17252 error
17253 .to_string()
17254 .contains("has no authenticated retained payload"),
17255 "was: {error}"
17256 );
17257 assert!(events.is_empty(), "was: {events:?}");
17258 assert_eq!(status, "broadcast");
17259 assert!(active);
17260
17261 drop_execution_schema(&admin_pool, &schema).await;
17262 }
17263
17264 #[tokio::test]
17265 async fn replacement_scan_rejects_rpc_fields_that_conflict_with_the_payload() {
17266 let expected_hash = expected_wrap_tx_hash(U256::from(1_u64)).await;
17267 let mut replacement_block: serde_json::Value =
17268 serde_json::from_str(&replacement_head_block(expected_hash)).unwrap();
17269 replacement_block["result"]["transactions"][1]["value"] = serde_json::json!("0x2");
17270 let replacement_block = replacement_block.to_string();
17271 let state = execution_rpc_state().with_parameter_response(
17272 "eth_getBlockByNumber",
17273 "0x1cf0d40",
17274 &replacement_block,
17275 );
17276 let Some((admin_pool, schema, client, _)) =
17277 execution_client_with_database("replacement_scan_payload_mismatch", state).await
17278 else {
17279 return;
17280 };
17281 let database = client.cache.database.as_ref().unwrap();
17282 let (intent, persisted_hash, persisted_payload) =
17283 persist_test_wrap_broadcast(database, client.payload_keys.as_deref()).await;
17284 assert_eq!(persisted_hash, expected_hash);
17285 let authenticated_payloads = HashMap::from([(persisted_hash, persisted_payload)]);
17286 let head = VerifiedBlockHeader {
17287 number: FIXTURE_BLOCK,
17288 hash: B256::from_str(FIXTURE_BLOCK_HASH).unwrap(),
17289 parent_hash: B256::from_str(
17290 "0x0000000000000000000000000000000000000000000000000000000000000001",
17291 )
17292 .unwrap(),
17293 timestamp: FIXTURE_BLOCK_TIMESTAMP,
17294 base_fee_per_gas: Some(100_000_000),
17295 };
17296
17297 let error = client
17298 .transaction_executor()
17299 .unwrap()
17300 .scan_canonical_replacement(&intent, 7, head, &authenticated_payloads)
17301 .await
17302 .unwrap_err();
17303 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
17304 "SELECT status, active FROM {schema}.execution_intent"
17305 )))
17306 .fetch_one(&admin_pool)
17307 .await
17308 .unwrap();
17309
17310 assert!(
17311 error
17312 .to_string()
17313 .contains("failed authenticated payload validation"),
17314 "was: {error}"
17315 );
17316 assert_eq!(status, "broadcast");
17317 assert!(active);
17318
17319 drop_execution_schema(&admin_pool, &schema).await;
17320 }
17321
17322 #[tokio::test]
17323 async fn execution_transaction_constraints_reject_conflicting_identity() {
17324 const TRANSACTION_HASH: &str = "0xduplicate-transaction-hash";
17325 const OTHER_WALLET: &str = "0x0000000000000000000000000000000000000001";
17326 let Some((admin_pool, schema, client, _)) = execution_client_with_unprotected_database(
17327 "execution_duplicate_record_test",
17328 ready_rpc_state(),
17329 )
17330 .await
17331 else {
17332 return;
17333 };
17334 let database = client.cache.database.as_ref().unwrap();
17335 let operator = database
17336 .reserve_execution_intent(&ExecutionIntentInsert {
17337 chain_id: 42161,
17338 wallet_address: WALLET.to_string(),
17339 purpose: "wrap".to_string(),
17340 client_order_id: None,
17341 trader_id: None,
17342 strategy_id: None,
17343 account_id: None,
17344 instrument_id: None,
17345 pool_address: None,
17346 transaction_to: WETH_ADDRESS.to_string(),
17347 transaction_input: "0xd0e30db0".to_string(),
17348 transaction_value: "1".to_string(),
17349 amount_in: None,
17350 created_block: FIXTURE_BLOCK,
17351 })
17352 .await
17353 .unwrap();
17354 database
17355 .assign_execution_intent_nonce(operator.id, 7)
17356 .await
17357 .unwrap();
17358 database
17359 .add_execution_transaction_hash(operator.id, 42161, TRANSACTION_HASH, &[1, 2, 3])
17360 .await
17361 .unwrap();
17362 database
17363 .add_execution_transaction_hash(operator.id, 42161, TRANSACTION_HASH, &[1, 2, 3])
17364 .await
17365 .unwrap();
17366
17367 let signer_conflict = database
17368 .reserve_execution_intent(&ExecutionIntentInsert {
17369 chain_id: 42161,
17370 wallet_address: WALLET.to_string(),
17371 purpose: "approve".to_string(),
17372 client_order_id: None,
17373 trader_id: None,
17374 strategy_id: None,
17375 account_id: None,
17376 instrument_id: None,
17377 pool_address: None,
17378 transaction_to: ROUTER_ADDRESS.to_string(),
17379 transaction_input: "0x01".to_string(),
17380 transaction_value: "0".to_string(),
17381 amount_in: None,
17382 created_block: FIXTURE_BLOCK,
17383 })
17384 .await
17385 .unwrap_err();
17386 let other = database
17387 .reserve_execution_intent(&ExecutionIntentInsert {
17388 chain_id: 42161,
17389 wallet_address: OTHER_WALLET.to_string(),
17390 purpose: "approve".to_string(),
17391 client_order_id: None,
17392 trader_id: None,
17393 strategy_id: None,
17394 account_id: None,
17395 instrument_id: None,
17396 pool_address: None,
17397 transaction_to: ROUTER_ADDRESS.to_string(),
17398 transaction_input: "0x01".to_string(),
17399 transaction_value: "0".to_string(),
17400 amount_in: None,
17401 created_block: FIXTURE_BLOCK,
17402 })
17403 .await
17404 .unwrap();
17405 database
17406 .assign_execution_intent_nonce(other.id, 7)
17407 .await
17408 .unwrap();
17409 let hash_conflict = database
17410 .add_execution_transaction_hash(other.id, 42161, TRANSACTION_HASH, &[4, 5, 6])
17411 .await
17412 .unwrap_err();
17413
17414 let record = database
17415 .get_execution_transaction(42161, TRANSACTION_HASH)
17416 .await
17417 .unwrap()
17418 .unwrap();
17419 let count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17420 "SELECT COUNT(*) FROM {schema}.execution_intent"
17421 )))
17422 .fetch_one(&admin_pool)
17423 .await
17424 .unwrap();
17425
17426 assert_eq!(record.wallet_address.as_deref(), Some(WALLET));
17427 assert_eq!(record.nonce, 7);
17428 assert_eq!(record.transaction_hash, TRANSACTION_HASH);
17429 assert_eq!(record.purpose, "wrap");
17430 assert_eq!(record.status, "signed");
17431 assert_eq!(record.client_order_id, None);
17432 assert!(
17433 hash_conflict
17434 .to_string()
17435 .contains("conflicts with its persisted identity"),
17436 "was: {hash_conflict}"
17437 );
17438 assert_eq!(
17439 signer_conflict.to_string(),
17440 "Execution intent reservation failed before commit"
17441 );
17442 assert!(
17443 signer_conflict.chain().any(|cause| cause
17444 .to_string()
17445 .contains("execution_intent_active_signer_key")),
17446 "was: {signer_conflict:#}"
17447 );
17448 assert_eq!(count, 2);
17449
17450 drop_execution_schema(&admin_pool, &schema).await;
17451 }
17452
17453 #[tokio::test]
17454 async fn execution_status_transitions_are_idempotent() {
17455 const TRANSACTION_HASH: &str =
17456 "0x5555555555555555555555555555555555555555555555555555555555555555";
17457 let Some((admin_pool, schema, client, _)) = execution_client_with_unprotected_database(
17458 "execution_transition_test",
17459 ready_rpc_state(),
17460 )
17461 .await
17462 else {
17463 return;
17464 };
17465 let database = client.cache.database.as_ref().unwrap();
17466 let intent = database
17467 .reserve_execution_intent(&ExecutionIntentInsert {
17468 chain_id: 42161,
17469 wallet_address: WALLET.to_string(),
17470 purpose: "wrap".to_string(),
17471 client_order_id: None,
17472 trader_id: None,
17473 strategy_id: None,
17474 account_id: None,
17475 instrument_id: None,
17476 pool_address: None,
17477 transaction_to: WETH_ADDRESS.to_string(),
17478 transaction_input: "0xd0e30db0".to_string(),
17479 transaction_value: "1".to_string(),
17480 amount_in: None,
17481 created_block: FIXTURE_BLOCK,
17482 })
17483 .await
17484 .unwrap();
17485 database
17486 .assign_execution_intent_nonce(intent.id, 7)
17487 .await
17488 .unwrap();
17489 database
17490 .add_execution_transaction_hash(intent.id, 42161, TRANSACTION_HASH, &[1, 2, 3])
17491 .await
17492 .unwrap();
17493
17494 for _ in 0..2 {
17495 database
17496 .record_execution_status(
17497 intent.id,
17498 TRANSACTION_HASH,
17499 TransactionStatus::Broadcast,
17500 None,
17501 None,
17502 None,
17503 None,
17504 None,
17505 )
17506 .await
17507 .unwrap();
17508 }
17509
17510 for status in [TransactionStatus::Included, TransactionStatus::Included] {
17511 database
17512 .record_execution_status(
17513 intent.id,
17514 TRANSACTION_HASH,
17515 status,
17516 Some(FIXTURE_BLOCK + 1),
17517 Some("0x2222222222222222222222222222222222222222222222222222222222222222"),
17518 Some(true),
17519 Some(50_112),
17520 Some("100000000"),
17521 )
17522 .await
17523 .unwrap();
17524 }
17525
17526 for _ in 0..2 {
17527 database
17528 .record_execution_status(
17529 intent.id,
17530 TRANSACTION_HASH,
17531 TransactionStatus::Finalized,
17532 Some(FIXTURE_BLOCK + 1),
17533 Some("0x2222222222222222222222222222222222222222222222222222222222222222"),
17534 Some(true),
17535 Some(50_112),
17536 Some("100000000"),
17537 )
17538 .await
17539 .unwrap();
17540 }
17541
17542 let transitions: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
17543 "SELECT to_status FROM {schema}.execution_transaction_transition ORDER BY id"
17544 )))
17545 .fetch_all(&admin_pool)
17546 .await
17547 .unwrap();
17548 let (status, active): (String, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
17549 "SELECT status, active FROM {schema}.execution_intent"
17550 )))
17551 .fetch_one(&admin_pool)
17552 .await
17553 .unwrap();
17554 let append_only_error = sqlx::query(sqlx::AssertSqlSafe(format!(
17555 "DELETE FROM {schema}.execution_transaction_transition WHERE intent_id = {}",
17556 intent.id
17557 )))
17558 .execute(&admin_pool)
17559 .await
17560 .unwrap_err();
17561
17562 assert_eq!(
17563 transitions,
17564 ["prepared", "signed", "broadcast", "included", "finalized"]
17565 );
17566 assert_eq!(status, "finalized");
17567 assert!(active);
17568 assert!(
17569 append_only_error
17570 .to_string()
17571 .contains("Execution transitions are append-only"),
17572 "was: {append_only_error}"
17573 );
17574
17575 drop_execution_schema(&admin_pool, &schema).await;
17576 }
17577
17578 #[tokio::test]
17579 async fn wrap_then_approve_persists_records_and_clears_in_flight() {
17580 let state = execution_rpc_state()
17581 .with_parameter_response_sequence(
17582 "eth_getBlockByNumber",
17583 "latest",
17584 &[
17585 BLOCK_BY_NUMBER,
17586 BLOCK_BY_NUMBER,
17587 BLOCK_BY_NUMBER,
17588 BLOCK_FINALIZED,
17589 BLOCK_FINALIZED,
17590 BLOCK_FINALIZED,
17591 ],
17592 )
17593 .with_response_sequence(
17594 "eth_call",
17595 &[
17596 CALL_BALANCE,
17597 CALL_BALANCE,
17598 CALL_BALANCE,
17599 CALL_BALANCE,
17600 CALL_BALANCE,
17601 CALL_BALANCE,
17602 CALL_BALANCE_AFTER_WRAP,
17603 CALL_BALANCE_AFTER_WRAP,
17604 CALL_BALANCE_AFTER_WRAP,
17605 CALL_BOOL_TRUE,
17606 CALL_BOOL_TRUE,
17607 CALL_BOOL_TRUE,
17608 ],
17609 )
17610 .with_call_response_sequence(
17611 ALLOWANCE_SELECTOR,
17612 &[
17613 CALL_ZERO,
17614 CALL_ZERO,
17615 CALL_ZERO,
17616 CALL_ALLOWANCE_MAX,
17617 CALL_ALLOWANCE_MAX,
17618 CALL_ALLOWANCE_MAX,
17619 ],
17620 )
17621 .with_response_sequence(
17622 "eth_getTransactionCount",
17623 &[
17624 TRANSACTION_COUNT,
17625 TRANSACTION_COUNT,
17626 TRANSACTION_COUNT,
17627 TRANSACTION_COUNT,
17628 TRANSACTION_COUNT,
17629 TRANSACTION_COUNT,
17630 TRANSACTION_COUNT_NEXT,
17631 TRANSACTION_COUNT_NEXT,
17632 TRANSACTION_COUNT_NEXT,
17633 TRANSACTION_COUNT_NEXT,
17634 TRANSACTION_COUNT_NEXT,
17635 TRANSACTION_COUNT_NEXT,
17636 ],
17637 )
17638 .with_response("eth_estimateGas", ESTIMATE_GAS)
17639 .with_response_sequence(
17640 "eth_getTransactionReceipt",
17641 &[
17642 RECEIPT_SUCCESS,
17643 RECEIPT_SUCCESS,
17644 RECEIPT_SUCCESS,
17645 RECEIPT_SUCCESS,
17646 RECEIPT_SUCCESS,
17647 RECEIPT_SUCCESS,
17648 ],
17649 )
17650 .with_send_raw_transaction_echo();
17651 let Some((admin_pool, schema, mut client, state)) =
17652 execution_client_with_database("execution_client_test", state).await
17653 else {
17654 return;
17655 };
17656 client.config.unlimited_approval = true;
17657 client.transaction_limits.receipt_timeout_secs = 3;
17658
17659 let wrap_hash = client
17660 .wrap(U256::from(1_000_000_000_000_000u64))
17661 .await
17662 .unwrap();
17663
17664 let record = client
17665 .cache
17666 .get_execution_transaction(42161, &wrap_hash.to_string())
17667 .await
17668 .unwrap()
17669 .unwrap();
17670 assert_eq!(record.nonce, 7);
17671 assert_eq!(record.purpose, "wrap");
17672 assert_eq!(record.status, "finalized");
17673 assert_eq!(
17674 execution_intent_markers(&admin_pool, &schema).await,
17675 vec![("wrap".into(), "finalized".into(), true, false)]
17676 );
17677
17678 let approve_hash = client
17679 .approve(WETH_ADDRESS, U256::from(1_000u64), ROUTER_ADDRESS)
17680 .await
17681 .unwrap();
17682
17683 let record = client
17684 .cache
17685 .get_execution_transaction(42161, &approve_hash.to_string())
17686 .await
17687 .unwrap()
17688 .unwrap();
17689 assert_eq!(record.nonce, 8);
17690 assert_eq!(record.purpose, "approve");
17691 assert_eq!(record.status, "finalized");
17692 assert_eq!(
17693 execution_intent_markers(&admin_pool, &schema).await,
17694 vec![
17695 ("wrap".into(), "finalized".into(), true, false),
17696 ("approve".into(), "finalized".into(), true, false),
17697 ]
17698 );
17699
17700 let requests = state.recorded_requests();
17701 let broadcasts: Vec<_> = requests
17702 .iter()
17703 .filter(|request| request["method"] == "eth_sendRawTransaction")
17704 .collect();
17705 assert_eq!(broadcasts.len(), 2);
17706 for broadcast in &broadcasts {
17707 let payload = broadcast["params"][0].as_str().unwrap();
17708 assert!(payload.starts_with("0x02"), "was: {payload}");
17709 }
17710 let broadcast_indexes = requests
17711 .iter()
17712 .enumerate()
17713 .filter_map(|(index, request)| {
17714 (request["method"] == "eth_sendRawTransaction").then_some(index)
17715 })
17716 .collect::<Vec<_>>();
17717
17718 for (index, expected_block) in broadcast_indexes
17719 .into_iter()
17720 .zip([FIXTURE_BLOCK_PARAM, "0x1cf0d42"])
17721 {
17722 let nonce_fence = &requests[index - 6..index];
17723 assert!(nonce_fence[..3].iter().all(|request| {
17724 request["method"] == "eth_getTransactionCount"
17725 && request["params"][1] == expected_block
17726 }));
17727 assert!(nonce_fence[3..].iter().all(|request| {
17728 request["method"] == "eth_getTransactionCount" && request["params"][1] == "pending"
17729 }));
17730 }
17731 let receipt_polls = requests
17732 .iter()
17733 .filter(|request| request["method"] == "eth_getTransactionReceipt")
17734 .count();
17735 assert_eq!(receipt_polls, 6);
17736 let allowance_calls: Vec<_> = requests
17737 .iter()
17738 .filter(|request| {
17739 request["method"] == "eth_call"
17740 && request["params"][0]["data"]
17741 .as_str()
17742 .is_some_and(|data| data.starts_with(ALLOWANCE_SELECTOR))
17743 })
17744 .collect();
17745 assert_eq!(allowance_calls.len(), 6);
17746 assert!(
17747 allowance_calls[..3]
17748 .iter()
17749 .all(|request| request["params"][1] == "0x1cf0d42")
17750 );
17751 assert!(
17752 allowance_calls[3..]
17753 .iter()
17754 .all(|request| request["params"][1] == "0x1cf0d41")
17755 );
17756
17757 let estimates: Vec<_> = requests
17759 .iter()
17760 .filter(|request| request["method"] == "eth_estimateGas")
17761 .collect();
17762 assert_eq!(estimates.len(), 6);
17763 assert!(estimates[..3].iter().all(|request| {
17764 request["params"].as_array().unwrap().len() == 2
17765 && request["params"][1] == FIXTURE_BLOCK_PARAM
17766 }));
17767 assert!(estimates[3..].iter().all(|request| {
17768 request["params"].as_array().unwrap().len() == 2 && request["params"][1] == "0x1cf0d42"
17769 }));
17770 let approve_data = estimates[3]["params"][0]["data"].as_str().unwrap();
17771 assert!(
17772 approve_data.starts_with("0x095ea7b3"),
17773 "was: {approve_data}"
17774 );
17775 assert!(
17776 approve_data.ends_with(&"f".repeat(64)),
17777 "was: {approve_data}"
17778 );
17779
17780 for selector in [FACTORY_SELECTOR, WETH9_SELECTOR] {
17781 let calls: Vec<_> = requests
17782 .iter()
17783 .filter(|request| {
17784 request["method"] == "eth_call"
17785 && request["params"][0]["data"]
17786 .as_str()
17787 .is_some_and(|data| data.starts_with(selector))
17788 })
17789 .collect();
17790 assert!(!calls.is_empty(), "selector {selector}");
17791 assert!(
17792 calls.iter().all(|request| request["params"][1]
17793 .as_str()
17794 .is_some_and(|block| block.starts_with("0x"))),
17795 "selector {selector}: {calls:?}"
17796 );
17797 }
17798 let latest_blocks = requests
17799 .iter()
17800 .filter(|request| {
17801 request["method"] == "eth_getBlockByNumber"
17802 && request["params"] == serde_json::json!(["latest", false])
17803 })
17804 .count();
17805 assert_eq!(latest_blocks, 6);
17806
17807 drop_execution_schema(&admin_pool, &schema).await;
17808 }
17809
17810 #[tokio::test]
17811 async fn reverted_receipt_marks_record_reverted_and_errors() {
17812 let state = signing_rpc_state()
17813 .with_response("eth_getTransactionReceipt", RECEIPT_REVERTED)
17814 .with_send_raw_transaction_echo();
17815 let Some((admin_pool, schema, mut client, _)) =
17816 execution_client_with_database("execution_reverted_test", state).await
17817 else {
17818 return;
17819 };
17820
17821 let error = client
17822 .wrap(U256::from(1_000_000_000_000_000u64))
17823 .await
17824 .unwrap_err();
17825
17826 assert!(
17827 error.to_string().contains("reverted on-chain"),
17828 "was: {error}"
17829 );
17830 assert!(client.in_flight.lock().is_none());
17831
17832 let expected_hash = expected_wrap_tx_hash(U256::from(1_000_000_000_000_000u64)).await;
17833
17834 let record = client
17835 .cache
17836 .get_execution_transaction(42161, &expected_hash.to_string())
17837 .await
17838 .unwrap()
17839 .unwrap();
17840 assert_eq!(record.status, "reverted");
17841 assert_eq!(
17842 execution_intent_markers(&admin_pool, &schema).await,
17843 vec![("wrap".into(), "reverted".into(), true, false)]
17844 );
17845
17846 drop_execution_schema(&admin_pool, &schema).await;
17847 }
17848
17849 fn market_sell_order_with_id(instrument_id: InstrumentId, client_order_id: &str) -> OrderAny {
17850 OrderTestBuilder::new(OrderType::Market)
17851 .trader_id(TraderId::from("TRADER-001"))
17852 .strategy_id(StrategyId::from("S-001"))
17853 .instrument_id(instrument_id)
17854 .client_order_id(ClientOrderId::from(client_order_id))
17855 .side(OrderSide::Sell)
17856 .quantity(Quantity::from("0.001"))
17857 .build()
17858 }
17859
17860 fn market_buy_order_with_id(instrument_id: InstrumentId, client_order_id: &str) -> OrderAny {
17861 OrderTestBuilder::new(OrderType::Market)
17862 .trader_id(TraderId::from("TRADER-001"))
17863 .strategy_id(StrategyId::from("S-001"))
17864 .instrument_id(instrument_id)
17865 .client_order_id(ClientOrderId::from(client_order_id))
17866 .side(OrderSide::Buy)
17867 .quantity(Quantity::from("0.001"))
17868 .build()
17869 }
17870
17871 fn submit_order_list_cmd(orders: &[OrderAny]) -> SubmitOrderList {
17872 let order_list = OrderList::new(
17873 OrderListId::from("OL-001"),
17874 orders[0].instrument_id(),
17875 orders[0].strategy_id(),
17876 orders.iter().map(|order| order.client_order_id()).collect(),
17877 UnixNanos::default(),
17878 );
17879 SubmitOrderList::new(
17880 TraderId::from("TRADER-001"),
17881 Some(ClientId::from("BLOCKCHAIN-001")),
17882 orders[0].strategy_id(),
17883 order_list,
17884 orders
17885 .iter()
17886 .map(|order| order.init_event().clone())
17887 .collect(),
17888 None,
17889 None,
17890 None,
17891 UUID4::new(),
17892 UnixNanos::default(),
17893 None,
17894 )
17895 }
17896
17897 fn modify_order_cmd(
17898 instrument_id: InstrumentId,
17899 client_order_id: ClientOrderId,
17900 ) -> ModifyOrder {
17901 ModifyOrder::new(
17902 TraderId::from("TRADER-001"),
17903 Some(ClientId::from("BLOCKCHAIN-001")),
17904 StrategyId::from("S-001"),
17905 instrument_id,
17906 client_order_id,
17907 None,
17908 Some(Quantity::from("0.002")),
17909 None,
17910 None,
17911 UUID4::new(),
17912 UnixNanos::default(),
17913 None,
17914 None,
17915 )
17916 }
17917
17918 fn cancel_order_cmd(
17919 instrument_id: InstrumentId,
17920 client_order_id: ClientOrderId,
17921 ) -> CancelOrder {
17922 CancelOrder::new(
17923 TraderId::from("TRADER-001"),
17924 Some(ClientId::from("BLOCKCHAIN-001")),
17925 StrategyId::from("S-001"),
17926 instrument_id,
17927 client_order_id,
17928 None,
17929 UUID4::new(),
17930 UnixNanos::default(),
17931 None,
17932 None,
17933 )
17934 }
17935
17936 fn cancel_all_orders_cmd(instrument_id: InstrumentId) -> CancelAllOrders {
17937 CancelAllOrders::new(
17938 TraderId::from("TRADER-001"),
17939 Some(ClientId::from("BLOCKCHAIN-001")),
17940 StrategyId::from("S-001"),
17941 instrument_id,
17942 Some(OrderSide::Sell),
17943 UUID4::new(),
17944 UnixNanos::default(),
17945 None,
17946 None,
17947 )
17948 }
17949
17950 fn batch_cancel_orders_cmd(cancels: Vec<CancelOrder>) -> BatchCancelOrders {
17951 BatchCancelOrders::new(
17952 TraderId::from("TRADER-001"),
17953 Some(ClientId::from("BLOCKCHAIN-001")),
17954 StrategyId::from("S-001"),
17955 cancels[0].instrument_id,
17956 cancels,
17957 UUID4::new(),
17958 UnixNanos::default(),
17959 None,
17960 None,
17961 )
17962 }
17963
17964 fn query_order_cmd(instrument_id: InstrumentId, client_order_id: ClientOrderId) -> QueryOrder {
17965 QueryOrder::new(
17966 TraderId::from("TRADER-001"),
17967 Some(ClientId::from("BLOCKCHAIN-001")),
17968 StrategyId::from("S-001"),
17969 instrument_id,
17970 client_order_id,
17971 None,
17972 UUID4::new(),
17973 UnixNanos::default(),
17974 None,
17975 None,
17976 )
17977 }
17978
17979 async fn unsupported_client_with_mock_rpc()
17980 -> (BlockchainExecutionClient, MockRpcState, Rc<RefCell<Cache>>) {
17981 let state = ready_rpc_state();
17982 let addr = start_mock_rpc_server(state.clone()).await;
17983 let (client, cache) = swap_client_with_cache(test_config(format!("http://{addr}")));
17984 (client, state, cache)
17985 }
17986
17987 #[tokio::test]
17988 async fn submit_order_denies_reduce_only_without_side_effects() {
17989 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
17990 let order = OrderTestBuilder::new(OrderType::Market)
17991 .trader_id(TraderId::from("TRADER-001"))
17992 .strategy_id(StrategyId::from("S-001"))
17993 .instrument_id(test_pool().instrument_id)
17994 .client_order_id(ClientOrderId::from("O-REDUCE-ONLY"))
17995 .side(OrderSide::Sell)
17996 .quantity(Quantity::from("0.001"))
17997 .reduce_only(true)
17998 .build();
17999 cache
18000 .borrow_mut()
18001 .add_order(order.clone(), None, None, false)
18002 .unwrap();
18003 let mut receiver = start_with_events(&mut client);
18004
18005 client.submit_order(submit_order_cmd(&order)).unwrap();
18006
18007 let events = collect_order_events(&mut receiver);
18008 assert_eq!(events.len(), 1, "was: {events:?}");
18009 let OrderEventAny::Denied(denied) = &events[0] else {
18010 panic!("expected OrderDenied, was {:?}", events[0]);
18011 };
18012 assert_eq!(denied.client_order_id, order.client_order_id());
18013 assert_eq!(denied.reason, "UNSUPPORTED_REDUCE_ONLY");
18014 assert!(state.recorded_requests().is_empty());
18015 assert!(client.in_flight.lock().is_none());
18016 }
18017
18018 #[tokio::test]
18019 async fn submit_order_list_denies_every_order_without_side_effects() {
18020 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18021 let pool = test_pool();
18022 let first = test_market_sell_order(pool.instrument_id);
18023 let second = market_sell_order_with_id(pool.instrument_id, "O-SWAP-002");
18024 cache
18025 .borrow_mut()
18026 .add_order(second.clone(), None, None, false)
18027 .unwrap();
18028 let orders = [first.clone(), second.clone()];
18029 let mut receiver = start_with_events(&mut client);
18030
18031 client
18032 .submit_order_list(submit_order_list_cmd(&orders))
18033 .unwrap();
18034
18035 let events = collect_order_events(&mut receiver);
18036 let mut denied_ids = Vec::new();
18037
18038 for event in &events {
18039 let OrderEventAny::Denied(denied) = event else {
18040 panic!("expected OrderDenied, was {event:?}");
18041 };
18042 assert_eq!(denied.reason, ORDER_LIST_UNSUPPORTED);
18043 denied_ids.push(denied.client_order_id);
18044 }
18045 denied_ids.sort();
18046 assert_eq!(
18047 denied_ids,
18048 [first.client_order_id(), second.client_order_id()]
18049 );
18050 assert!(state.recorded_requests().is_empty());
18051 assert!(client.in_flight.lock().is_none());
18052
18053 for order in &orders {
18054 let cache_ref = cache.borrow();
18055 let cached = cache_ref.order(&order.client_order_id()).unwrap();
18056 assert_eq!(cached.status(), OrderStatus::Initialized);
18057 }
18058 }
18059
18060 #[tokio::test]
18061 async fn modify_order_rejects_without_side_effects() {
18062 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18063 let order = test_market_sell_order(test_pool().instrument_id);
18064 let mut receiver = start_with_events(&mut client);
18065
18066 client
18067 .modify_order(modify_order_cmd(
18068 order.instrument_id(),
18069 order.client_order_id(),
18070 ))
18071 .unwrap();
18072
18073 let events = collect_order_events(&mut receiver);
18074 assert_eq!(events.len(), 1, "was: {events:?}");
18075 let OrderEventAny::ModifyRejected(rejected) = &events[0] else {
18076 panic!("expected OrderModifyRejected, was {:?}", events[0]);
18077 };
18078 assert_eq!(rejected.client_order_id, order.client_order_id());
18079 assert_eq!(rejected.reason, ORDER_MODIFY_UNSUPPORTED);
18080 assert!(state.recorded_requests().is_empty());
18081 assert!(client.in_flight.lock().is_none());
18082 let cache_ref = cache.borrow();
18083 let cached = cache_ref.order(&order.client_order_id()).unwrap();
18084 assert_eq!(cached.status(), OrderStatus::Initialized);
18085 }
18086
18087 #[tokio::test]
18088 async fn cancel_order_rejects_without_side_effects() {
18089 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18090 let order = test_market_sell_order(test_pool().instrument_id);
18091 let mut receiver = start_with_events(&mut client);
18092
18093 client
18094 .cancel_order(cancel_order_cmd(
18095 order.instrument_id(),
18096 order.client_order_id(),
18097 ))
18098 .unwrap();
18099
18100 let events = collect_order_events(&mut receiver);
18101 assert_eq!(events.len(), 1, "was: {events:?}");
18102 let OrderEventAny::CancelRejected(rejected) = &events[0] else {
18103 panic!("expected OrderCancelRejected, was {:?}", events[0]);
18104 };
18105 assert_eq!(rejected.client_order_id, order.client_order_id());
18106 assert_eq!(rejected.reason, ORDER_CANCEL_UNSUPPORTED);
18107 assert!(state.recorded_requests().is_empty());
18108 assert!(client.in_flight.lock().is_none());
18109 let cache_ref = cache.borrow();
18110 let cached = cache_ref.order(&order.client_order_id()).unwrap();
18111 assert_eq!(cached.status(), OrderStatus::Initialized);
18112 }
18113
18114 #[tokio::test]
18115 async fn batch_cancel_orders_rejects_each_order_without_side_effects() {
18116 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18117 let pool = test_pool();
18118 let first = test_market_sell_order(pool.instrument_id);
18119 let second = market_sell_order_with_id(pool.instrument_id, "O-SWAP-002");
18120 cache
18121 .borrow_mut()
18122 .add_order(second.clone(), None, None, false)
18123 .unwrap();
18124 let orders = [first.clone(), second.clone()];
18125 let cancels = orders
18126 .iter()
18127 .map(|order| cancel_order_cmd(order.instrument_id(), order.client_order_id()))
18128 .collect();
18129 let mut receiver = start_with_events(&mut client);
18130
18131 client
18132 .batch_cancel_orders(batch_cancel_orders_cmd(cancels))
18133 .unwrap();
18134
18135 let events = collect_order_events(&mut receiver);
18136 let mut rejected_ids = Vec::new();
18137
18138 for event in &events {
18139 let OrderEventAny::CancelRejected(rejected) = event else {
18140 panic!("expected OrderCancelRejected, was {event:?}");
18141 };
18142 assert_eq!(rejected.reason, ORDER_CANCEL_UNSUPPORTED);
18143 rejected_ids.push(rejected.client_order_id);
18144 }
18145 rejected_ids.sort();
18146 assert_eq!(
18147 rejected_ids,
18148 [first.client_order_id(), second.client_order_id()]
18149 );
18150 assert!(state.recorded_requests().is_empty());
18151 assert!(client.in_flight.lock().is_none());
18152 }
18153
18154 #[tokio::test]
18155 async fn cancel_all_and_query_order_log_unsupported_without_side_effects() {
18156 let (mut client, state, cache) = unsupported_client_with_mock_rpc().await;
18157 let order = test_market_sell_order(test_pool().instrument_id);
18158 let mut receiver = start_with_events(&mut client);
18159
18160 client
18161 .cancel_all_orders(cancel_all_orders_cmd(order.instrument_id()))
18162 .unwrap();
18163 client
18164 .query_order(query_order_cmd(
18165 order.instrument_id(),
18166 order.client_order_id(),
18167 ))
18168 .unwrap();
18169
18170 let events = collect_order_events(&mut receiver);
18171 assert!(events.is_empty(), "was: {events:?}");
18172 assert!(state.recorded_requests().is_empty());
18173 assert!(client.in_flight.lock().is_none());
18174 let cache_ref = cache.borrow();
18175 let cached = cache_ref.order(&order.client_order_id()).unwrap();
18176 assert_eq!(cached.status(), OrderStatus::Initialized);
18177 }
18178
18179 #[tokio::test]
18180 async fn unsupported_commands_handle_unknown_orders_without_panic() {
18181 let (mut client, state, _) = unsupported_client_with_mock_rpc().await;
18182 let instrument_id = test_pool().instrument_id;
18183 let unknown = ClientOrderId::from("O-UNKNOWN");
18184 let mut receiver = start_with_events(&mut client);
18185
18186 client
18187 .modify_order(modify_order_cmd(instrument_id, unknown))
18188 .unwrap();
18189 client
18190 .cancel_order(cancel_order_cmd(instrument_id, unknown))
18191 .unwrap();
18192 client
18193 .batch_cancel_orders(batch_cancel_orders_cmd(vec![cancel_order_cmd(
18194 instrument_id,
18195 unknown,
18196 )]))
18197 .unwrap();
18198 client
18199 .query_order(query_order_cmd(instrument_id, unknown))
18200 .unwrap();
18201
18202 let events = collect_order_events(&mut receiver);
18203 assert!(events.is_empty(), "was: {events:?}");
18204 assert!(state.recorded_requests().is_empty());
18205 assert!(client.in_flight.lock().is_none());
18206 }
18207
18208 #[tokio::test]
18209 async fn report_generators_error_except_mass_status_without_side_effects() {
18210 let (client, state, _) = unsupported_client_with_mock_rpc().await;
18211
18212 let report = client
18213 .generate_order_status_report(&GenerateOrderStatusReport::new(
18214 UUID4::new(),
18215 UnixNanos::default(),
18216 None,
18217 None,
18218 None,
18219 None,
18220 None,
18221 ))
18222 .await
18223 .unwrap_err();
18224 assert_eq!(report.to_string(), VENUE_EXECUTION_REPORTS_UNSUPPORTED);
18225
18226 let reports = client
18227 .generate_order_status_reports(&GenerateOrderStatusReports::new(
18228 UUID4::new(),
18229 UnixNanos::default(),
18230 false,
18231 None,
18232 None,
18233 None,
18234 None,
18235 None,
18236 ))
18237 .await
18238 .unwrap_err();
18239 assert_eq!(reports.to_string(), VENUE_EXECUTION_REPORTS_UNSUPPORTED);
18240
18241 let fills = client
18242 .generate_fill_reports(GenerateFillReports::new(
18243 UUID4::new(),
18244 UnixNanos::default(),
18245 None,
18246 None,
18247 None,
18248 None,
18249 None,
18250 None,
18251 ))
18252 .await
18253 .unwrap_err();
18254 assert_eq!(fills.to_string(), VENUE_EXECUTION_REPORTS_UNSUPPORTED);
18255
18256 let positions = client
18257 .generate_position_status_reports(&GeneratePositionStatusReports::new(
18258 UUID4::new(),
18259 UnixNanos::default(),
18260 None,
18261 None,
18262 None,
18263 None,
18264 None,
18265 ))
18266 .await
18267 .unwrap_err();
18268 assert_eq!(positions.to_string(), VENUE_EXECUTION_REPORTS_UNSUPPORTED);
18269
18270 let mass_status = client.generate_mass_status(None).await.unwrap();
18271 assert!(mass_status.is_none());
18272
18273 let mass_status = client.generate_mass_status(Some(60)).await.unwrap();
18274 assert!(mass_status.is_none());
18275
18276 assert!(state.recorded_requests().is_empty());
18277 assert!(client.in_flight.lock().is_none());
18278 }
18279
18280 async fn connect_test_postgres(
18281 test_name: &str,
18282 ) -> Option<(sqlx::PgPool, PostgresConnectOptions)> {
18283 let pg_config = get_postgres_connect_options(None, None, None, None, None);
18284 let admin_options: sqlx::postgres::PgConnectOptions = pg_config.clone().into();
18285 let admin_pool = match PgPoolOptions::new()
18286 .max_connections(1)
18287 .connect_with(admin_options)
18288 .await
18289 {
18290 Ok(pool) => pool,
18291 Err(e) => {
18292 eprintln!("Postgres unavailable; skipping {test_name} test: {e}");
18293 return None;
18294 }
18295 };
18296
18297 Some((admin_pool, pg_config))
18298 }
18299
18300 async fn execution_client_with_database(
18301 test_name: &str,
18302 state: MockRpcState,
18303 ) -> Option<(
18304 sqlx::PgPool,
18305 String,
18306 BlockchainExecutionClient,
18307 MockRpcState,
18308 )> {
18309 let (admin_pool, schema, mut client, state) =
18310 execution_client_with_unprotected_database(test_name, state).await?;
18311 protect_test_storage(&mut client, &schema).await;
18312 Some((admin_pool, schema, client, state))
18313 }
18314
18315 async fn execution_client_with_unprotected_database(
18316 test_name: &str,
18317 state: MockRpcState,
18318 ) -> Option<(
18319 sqlx::PgPool,
18320 String,
18321 BlockchainExecutionClient,
18322 MockRpcState,
18323 )> {
18324 let (admin_pool, pg_config) = connect_test_postgres(test_name).await?;
18325 let schema = format!("{test_name}_{}", std::process::id());
18326 setup_execution_schema(&admin_pool, &schema).await;
18327
18328 let db_options: sqlx::postgres::PgConnectOptions = pg_config.into();
18329 let db_options = db_options.options([("search_path", schema.clone())]);
18330 let database = connect_test_database(db_options).await.unwrap();
18331 let addr = start_mock_rpc_server(state.clone()).await;
18332 let mut client = test_client(format!("http://{addr}"));
18333 client.cache.database = Some(database);
18334 client
18336 .cache
18337 .ensure_execution_transaction_schema()
18338 .await
18339 .unwrap();
18340 initialize_test_verification_ledger(&client).await;
18341 client.signer = Some(Arc::new(
18342 PrivateKeySigner::from_str(TEST_PRIVATE_KEY).unwrap(),
18343 ));
18344 client.core.set_connected();
18345
18346 Some((admin_pool, schema, client, state))
18347 }
18348
18349 async fn install_reservation_commit_rejection(admin_pool: &sqlx::PgPool, schema: &str) {
18350 for statement in [
18351 format!(
18352 "CREATE FUNCTION {schema}.reject_execution_reservation_commit() RETURNS trigger \
18353 LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''test reservation commit rejection''; \
18354 RETURN NEW; END'"
18355 ),
18356 format!(
18357 "CREATE CONSTRAINT TRIGGER reject_execution_reservation_commit AFTER INSERT ON \
18358 {schema}.execution_transaction_transition DEFERRABLE INITIALLY DEFERRED \
18359 FOR EACH ROW EXECUTE FUNCTION {schema}.reject_execution_reservation_commit()"
18360 ),
18361 ] {
18362 sqlx::query(sqlx::AssertSqlSafe(statement))
18363 .execute(admin_pool)
18364 .await
18365 .unwrap();
18366 }
18367 }
18368
18369 async fn install_recoverable_commit_rejection(admin_pool: &sqlx::PgPool, schema: &str) {
18370 for statement in [
18371 format!(
18372 "CREATE FUNCTION {schema}.reject_recoverable_commit() RETURNS trigger \
18373 LANGUAGE plpgsql AS 'BEGIN IF NEW.transition_key = ''recoverable'' THEN \
18374 RAISE EXCEPTION ''test recoverable commit rejection''; END IF; \
18375 RETURN NEW; END'"
18376 ),
18377 format!(
18378 "CREATE CONSTRAINT TRIGGER reject_recoverable_commit AFTER INSERT ON \
18379 {schema}.execution_transaction_transition DEFERRABLE INITIALLY DEFERRED \
18380 FOR EACH ROW EXECUTE FUNCTION {schema}.reject_recoverable_commit()"
18381 ),
18382 ] {
18383 sqlx::query(sqlx::AssertSqlSafe(statement))
18384 .execute(admin_pool)
18385 .await
18386 .unwrap();
18387 }
18388 }
18389
18390 async fn drop_execution_schema(admin_pool: &sqlx::PgPool, schema: &str) {
18391 sqlx::query(sqlx::AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE")))
18392 .execute(admin_pool)
18393 .await
18394 .unwrap();
18395 }
18396
18397 async fn setup_execution_schema(admin_pool: &sqlx::PgPool, schema: &str) {
18398 for statement in [
18399 format!("CREATE SCHEMA {schema}"),
18400 format!(
18401 r#"CREATE TABLE {schema}."chain" (chain_id INTEGER PRIMARY KEY, name TEXT NOT NULL)"#
18402 ),
18403 format!(r#"INSERT INTO {schema}."chain" (chain_id, name) VALUES (42161, 'Arbitrum')"#),
18404 format!(
18405 r#"CREATE TABLE {schema}."execution_transaction" (
18406 id BIGSERIAL PRIMARY KEY,
18407 chain_id INTEGER NOT NULL REFERENCES {schema}."chain"(chain_id) ON DELETE CASCADE,
18408 nonce BIGINT NOT NULL,
18409 transaction_hash TEXT NOT NULL,
18410 purpose TEXT NOT NULL,
18411 status TEXT NOT NULL,
18412 UNIQUE (chain_id, transaction_hash)
18413 )"#
18414 ),
18415 ] {
18416 sqlx::query(sqlx::AssertSqlSafe(statement))
18417 .execute(admin_pool)
18418 .await
18419 .unwrap();
18420 }
18421 }
18422
18423 fn execution_transaction_create_sql() -> &'static str {
18424 const TABLES_SQL: &str = include_str!("../../../../../schema/sql/tables.sql");
18425 const START: &str = "CREATE TABLE IF NOT EXISTS \"execution_transaction\"";
18426 let start = TABLES_SQL
18427 .find(START)
18428 .expect("execution_transaction table is missing from tables.sql");
18429 let statement = &TABLES_SQL[start..];
18430 let end = statement
18431 .find(";\n")
18432 .expect("execution_transaction CREATE TABLE is unterminated")
18433 + 1;
18434 &statement[..end]
18435 }
18436}