1use std::{collections::BTreeSet, fmt::Display, pin::Pin, sync::Arc};
17
18use alloy::primitives::{Address, U256};
19use anyhow::Context;
20use futures_util::{Stream, StreamExt};
21use nautilus_model::{
22 defi::{
23 Block, Chain, DexType, Pool, PoolIdentifier, PoolLiquidityUpdate, PoolSwap, SharedChain,
24 SharedDex, Token,
25 data::{
26 DexPoolData, PoolFeeCollect, PoolFeeProtocolCollect, PoolFeeProtocolUpdate, PoolFlash,
27 block::{BLOCK_SCOPED_SNAPSHOT_INDEX, BlockPosition},
28 },
29 pool_analysis::{
30 position::PoolPosition,
31 snapshot::{PoolAnalytics, PoolSnapshot, PoolState},
32 },
33 tick_map::tick::PoolTick,
34 validation::validate_address,
35 },
36 identifiers::InstrumentId,
37};
38use rust_decimal::Decimal;
39use sqlx::{
40 AssertSqlSafe, PgPool, Postgres, Row, Transaction,
41 postgres::{PgAdvisoryLock, PgAdvisoryLockKey, PgConnectOptions},
42};
43
44use crate::{
45 cache::{
46 PoolEventSyncState,
47 consistency::CachedBlocksConsistencyStatus,
48 copy::PostgresCopyHandler,
49 rows::{
50 BlockTimestampRow, ExecutionIntentInsert, ExecutionIntentRow,
51 ExecutionTransactionHashRow, ExecutionTransactionRow, PoolRow, TokenRow,
52 parse_cached_block_timestamp, transform_row_to_dex_pool_data,
53 },
54 types::{U128Pg, U256Pg},
55 },
56 events::initialize::InitializeEvent,
57 execution::{
58 sealing::{
59 PayloadKeySet, PayloadPolicy, authenticate_payload, authenticate_retained_payload,
60 envelope_key_id, payload_context, retained_payload_requires_policy,
61 },
62 transaction::{EXECUTION_SCHEMA_VERSION, TransactionStatus},
63 },
64 rpc::verification::VERIFICATION_SCHEMA_VERSION,
65};
66
67const EXECUTION_PAYLOAD_COMPONENT: &str = "evm_execution_payload";
68const EXECUTION_PAYLOAD_PROTOCOL_VERSION: i16 = 1;
69const EXECUTION_PAYLOAD_BATCH_SIZE: i64 = 100;
70const EXECUTION_PAYLOAD_MAX_SEALS: i64 = 4_294_967_296;
71
72pub(crate) struct ExecutionVerificationBootstrap<'a> {
73 pub chain_id: u32,
74 pub wallet_address: &'a str,
75 pub manifest_version: &'a str,
76 pub manifest_digest: &'a str,
77 pub checkpoint_number: u64,
78 pub checkpoint_hash: &'a str,
79 pub checkpoint_parent_hash: &'a str,
80 pub checkpoint_timestamp: u64,
81 pub checkpoint_base_fee_per_gas: Option<u128>,
82 pub finalized_headers: &'a [ExecutionVerifiedHeader],
83 pub next_canonical_nonce: u64,
84 pub observed_canonical_nonce: u64,
85 pub provider_ids: &'a [String],
86 pub operator_ids: &'a [String],
87 pub failure_domain_ids: &'a [String],
88 pub decisions: &'a [ExecutionVerificationDecision],
89 pub migration: Option<&'a ExecutionVerificationMigration>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub(crate) struct ExecutionVerificationMigrationSnapshot {
94 pub intents: Vec<ExecutionIntentRow>,
95 pub hashes: Vec<ExecutionTransactionHashRow>,
96}
97
98pub(crate) struct ExecutionVerificationMigration {
99 pub snapshot: ExecutionVerificationMigrationSnapshot,
100 pub records: Vec<ExecutionVerificationMigrationRecord>,
101}
102
103pub(crate) struct ExecutionVerificationMigrationRecord {
104 pub intent_id: i64,
105 pub nonce: Option<u64>,
106 pub transaction_hash: Option<String>,
107 pub terminal_status: Option<TransactionStatus>,
108 pub block_number: Option<u64>,
109 pub block_hash: Option<String>,
110 pub receipt_success: Option<bool>,
111 pub gas_used: Option<u64>,
112 pub effective_gas_price: Option<String>,
113 pub recover_prepared: bool,
114 pub decisions: Vec<ExecutionVerificationDecision>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub(crate) struct ExecutionVerifiedHeader {
119 pub number: u64,
120 pub hash: String,
121 pub parent_hash: String,
122 pub timestamp: u64,
123 pub base_fee_per_gas: Option<u128>,
124}
125
126pub(crate) struct ExecutionVerificationPosition {
127 pub next_canonical_nonce: u64,
128 pub revision: u64,
129 pub finalized_tip: ExecutionVerifiedHeader,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub(crate) struct ExecutionVerificationDecision {
134 pub read_class: &'static str,
135 pub height_start: Option<u64>,
136 pub height_end: Option<u64>,
137 pub normalized_value_digest: String,
138}
139
140pub(crate) struct ExecutionNonceAssignment<'a> {
141 pub intent_id: i64,
142 pub chain_id: u32,
143 pub wallet_address: &'a str,
144 pub nonce: u64,
145 pub manifest_version: &'a str,
146 pub manifest_digest: &'a str,
147 pub provider_ids: &'a [String],
148 pub operator_ids: &'a [String],
149 pub failure_domain_ids: &'a [String],
150 pub decisions: &'a [ExecutionVerificationDecision],
151}
152
153pub(crate) struct ExecutionFinalityTransition<'a> {
154 pub intent_id: i64,
155 pub chain_id: u32,
156 pub wallet_address: &'a str,
157 pub nonce: u64,
158 pub transaction_hash: &'a str,
159 pub status: TransactionStatus,
160 pub block_number: u64,
161 pub block_hash: &'a str,
162 pub receipt_success: bool,
163 pub gas_used: u64,
164 pub effective_gas_price: &'a str,
165 pub manifest_version: &'a str,
166 pub manifest_digest: &'a str,
167 pub provider_ids: &'a [String],
168 pub operator_ids: &'a [String],
169 pub failure_domain_ids: &'a [String],
170 pub decisions: &'a [ExecutionVerificationDecision],
171 pub finalized_headers: &'a [ExecutionVerifiedHeader],
172}
173
174pub(crate) struct ExecutionVerificationBatch<'a> {
175 pub intent_id: i64,
176 pub chain_id: u32,
177 pub wallet_address: &'a str,
178 pub nonce: u64,
179 pub decision_class: &'a str,
180 pub manifest_version: &'a str,
181 pub manifest_digest: &'a str,
182 pub provider_ids: &'a [String],
183 pub operator_ids: &'a [String],
184 pub failure_domain_ids: &'a [String],
185 pub decisions: &'a [ExecutionVerificationDecision],
186}
187
188pub(crate) struct ExecutionReplacementScan<'a> {
189 pub intent_id: i64,
190 pub chain_id: u32,
191 pub wallet_address: &'a str,
192 pub nonce: u64,
193 pub finalized_cursor: Option<&'a ExecutionVerifiedHeader>,
194 pub matched_transaction_hash: Option<&'a str>,
195 pub manifest_version: &'a str,
196 pub manifest_digest: &'a str,
197 pub provider_ids: &'a [String],
198 pub operator_ids: &'a [String],
199 pub failure_domain_ids: &'a [String],
200 pub decisions: &'a [ExecutionVerificationDecision],
201}
202const EXECUTION_PAYLOAD_INFRASTRUCTURE_QUERY: &str = "
203 SELECT
204 EXISTS (
205 SELECT 1
206 FROM pg_catalog.pg_trigger AS t
207 JOIN pg_catalog.pg_class AS c ON c.oid = t.tgrelid
208 JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
209 WHERE n.nspname = current_schema()
210 AND c.relname = 'execution_transaction_hash'
211 AND t.tgname = 'execution_transaction_payload_fence'
212 AND NOT t.tgisinternal
213 ),
214 EXISTS (
215 SELECT 1
216 FROM pg_catalog.pg_constraint AS con
217 JOIN pg_catalog.pg_class AS c ON c.oid = con.conrelid
218 JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
219 WHERE n.nspname = current_schema()
220 AND c.relname = 'execution_transaction_hash'
221 AND con.conname = 'execution_transaction_payload_protected_check'
222 AND con.contype = 'c'
223 )
224";
225
226#[derive(Debug)]
227struct ExecutionPayloadState {
228 deployment_id: String,
229 protocol_version: i16,
230 operation: String,
231 active_key_id: Vec<u8>,
232}
233
234pub(crate) struct ExecutionPayloadLease {
236 _transaction: Transaction<'static, Postgres>,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub(crate) struct ExecutionPayloadCheck {
241 pub protected: bool,
242 pub deployment_id: Option<String>,
243 pub plaintext_rows: u64,
244 pub original_rows: u64,
245 pub replacement_rows: u64,
246 pub authenticated_rows: u64,
247 pub key_ids: Vec<String>,
248 pub read_roles: Vec<String>,
249}
250
251#[derive(Debug, Clone)]
253pub struct BlockchainCacheDatabase {
254 pool: PgPool,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259enum ExecutionIntentReservationStage {
260 BeforeCommit,
261 Commit,
262}
263
264#[derive(Debug)]
265struct ExecutionIntentReservationError {
266 stage: ExecutionIntentReservationStage,
267 source: anyhow::Error,
268}
269
270impl Display for ExecutionIntentReservationError {
271 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272 match self.stage {
273 ExecutionIntentReservationStage::BeforeCommit => {
274 f.write_str("Execution intent reservation failed before commit")
275 }
276 ExecutionIntentReservationStage::Commit => f.write_str(
277 "Execution intent reservation commit outcome is unknown; reconciliation is required",
278 ),
279 }
280 }
281}
282
283impl std::error::Error for ExecutionIntentReservationError {
284 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
285 Some(self.source.as_ref())
286 }
287}
288
289pub(crate) fn reservation_failure_proven_not_committed(error: &anyhow::Error) -> bool {
290 error
291 .downcast_ref::<ExecutionIntentReservationError>()
292 .is_some_and(|e| e.stage == ExecutionIntentReservationStage::BeforeCommit)
293}
294
295const POOL_ROW_COLUMNS: &str = "
297 address,
298 pool_identifier,
299 dex_name,
300 creation_block,
301 COALESCE(
302 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool.chain_id AND block.number = pool.creation_block),
303 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool.chain_id AND pool_event_block.number = pool.creation_block)
304 ) as creation_block_timestamp,
305 token0_chain,
306 token0_address,
307 token1_chain,
308 token1_address,
309 fee,
310 tick_spacing,
311 initial_tick,
312 initial_sqrt_price_x96,
313 hook_address
314";
315
316impl BlockchainCacheDatabase {
317 pub async fn init(pg_options: PgConnectOptions) -> Self {
323 Self::connect(pg_options)
324 .await
325 .expect("Error connecting to Postgres")
326 }
327
328 pub async fn connect(pg_options: PgConnectOptions) -> anyhow::Result<Self> {
334 let pool = sqlx::postgres::PgPoolOptions::new()
335 .max_connections(32) .min_connections(5) .acquire_timeout(std::time::Duration::from_secs(3))
338 .connect_with(pg_options)
339 .await?;
340 Ok(Self { pool })
341 }
342
343 pub async fn seed_chain(&self, chain: &Chain) -> anyhow::Result<()> {
349 sqlx::query(
350 "
351 INSERT INTO chain (
352 chain_id, name
353 ) VALUES ($1,$2)
354 ON CONFLICT (chain_id)
355 DO NOTHING
356 ",
357 )
358 .bind(chain.chain_id as i32)
359 .bind(chain.name.to_string())
360 .execute(&self.pool)
361 .await
362 .map(|_| ())
363 .map_err(|e| anyhow::anyhow!("Failed to seed chain table: {e}"))
364 }
365
366 pub async fn create_block_partition(&self, chain: &Chain) -> anyhow::Result<String> {
373 let result: (String,) = sqlx::query_as("SELECT create_block_partition($1)")
374 .bind(chain.chain_id as i32)
375 .fetch_one(&self.pool)
376 .await
377 .map_err(|e| {
378 anyhow::anyhow!(
379 "Failed to call create_block_partition for chain {}: {e}",
380 chain.chain_id
381 )
382 })?;
383
384 Ok(result.0)
385 }
386
387 pub async fn create_token_partition(&self, chain: &Chain) -> anyhow::Result<String> {
394 let result: (String,) = sqlx::query_as("SELECT create_token_partition($1)")
395 .bind(chain.chain_id as i32)
396 .fetch_one(&self.pool)
397 .await
398 .map_err(|e| {
399 anyhow::anyhow!(
400 "Failed to call create_token_partition for chain {}: {e}",
401 chain.chain_id
402 )
403 })?;
404
405 Ok(result.0)
406 }
407
408 pub async fn get_block_consistency_status(
414 &self,
415 chain: &Chain,
416 ) -> anyhow::Result<CachedBlocksConsistencyStatus> {
417 log::debug!("Fetching block consistency status");
418
419 let result: (i64, i64) = sqlx::query_as(
420 "
421 SELECT
422 COALESCE((SELECT number FROM block WHERE chain_id = $1 ORDER BY number DESC LIMIT 1), 0) as max_block,
423 get_last_continuous_block($1) as last_continuous_block
424 "
425 )
426 .bind(chain.chain_id as i32)
427 .fetch_one(&self.pool)
428 .await
429 .map_err(|e| {
430 anyhow::anyhow!(
431 "Failed to get block info for chain {}: {}",
432 chain.chain_id,
433 e
434 )
435 })?;
436
437 Ok(CachedBlocksConsistencyStatus::new(
438 result.0 as u64,
439 result.1 as u64,
440 ))
441 }
442
443 pub async fn add_block(&self, chain_id: u32, block: &Block) -> anyhow::Result<()> {
449 sqlx::query(
450 "
451 INSERT INTO block (
452 chain_id, number, hash, parent_hash, miner, gas_limit, gas_used, timestamp,
453 base_fee_per_gas, blob_gas_used, excess_blob_gas,
454 l1_gas_price, l1_gas_used, l1_fee_scalar
455 ) VALUES (
456 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
457 )
458 ON CONFLICT (chain_id, number)
459 DO UPDATE
460 SET
461 hash = $3,
462 parent_hash = $4,
463 miner = $5,
464 gas_limit = $6,
465 gas_used = $7,
466 timestamp = $8,
467 base_fee_per_gas = $9,
468 blob_gas_used = $10,
469 excess_blob_gas = $11,
470 l1_gas_price = $12,
471 l1_gas_used = $13,
472 l1_fee_scalar = $14
473 ",
474 )
475 .bind(chain_id as i32)
476 .bind(block.number as i64)
477 .bind(block.hash.as_str())
478 .bind(block.parent_hash.as_str())
479 .bind(block.miner.as_str())
480 .bind(block.gas_limit as i64)
481 .bind(block.gas_used as i64)
482 .bind(block.timestamp.to_string())
483 .bind(block.base_fee_per_gas.as_ref().map(U256::to_string))
484 .bind(block.blob_gas_used.as_ref().map(U256::to_string))
485 .bind(block.excess_blob_gas.as_ref().map(U256::to_string))
486 .bind(block.l1_gas_price.as_ref().map(U256::to_string))
487 .bind(block.l1_gas_used.map(|v| v as i64))
488 .bind(block.l1_fee_scalar.map(|v| v as i64))
489 .execute(&self.pool)
490 .await
491 .map(|_| ())
492 .map_err(|e| anyhow::anyhow!("Failed to insert into block table: {e}"))
493 }
494
495 pub async fn add_blocks_batch(&self, chain_id: u32, blocks: &[Block]) -> anyhow::Result<()> {
501 if blocks.is_empty() {
502 return Ok(());
503 }
504
505 let mut numbers: Vec<i64> = Vec::with_capacity(blocks.len());
507 let mut hashes: Vec<String> = Vec::with_capacity(blocks.len());
508 let mut parent_hashes: Vec<String> = Vec::with_capacity(blocks.len());
509 let mut miners: Vec<String> = Vec::with_capacity(blocks.len());
510 let mut gas_limits: Vec<i64> = Vec::with_capacity(blocks.len());
511 let mut gas_useds: Vec<i64> = Vec::with_capacity(blocks.len());
512 let mut timestamps: Vec<String> = Vec::with_capacity(blocks.len());
513 let mut base_fee_per_gases: Vec<Option<String>> = Vec::with_capacity(blocks.len());
514 let mut blob_gas_useds: Vec<Option<String>> = Vec::with_capacity(blocks.len());
515 let mut excess_blob_gases: Vec<Option<String>> = Vec::with_capacity(blocks.len());
516 let mut l1_gas_prices: Vec<Option<String>> = Vec::with_capacity(blocks.len());
517 let mut l1_gas_useds: Vec<Option<i64>> = Vec::with_capacity(blocks.len());
518 let mut l1_fee_scalars: Vec<Option<i64>> = Vec::with_capacity(blocks.len());
519
520 for block in blocks {
522 numbers.push(block.number as i64);
523 hashes.push(block.hash.clone());
524 parent_hashes.push(block.parent_hash.clone());
525 miners.push(block.miner.to_string());
526 gas_limits.push(block.gas_limit as i64);
527 gas_useds.push(block.gas_used as i64);
528 timestamps.push(block.timestamp.to_string());
529 base_fee_per_gases.push(block.base_fee_per_gas.as_ref().map(U256::to_string));
530 blob_gas_useds.push(block.blob_gas_used.as_ref().map(U256::to_string));
531 excess_blob_gases.push(block.excess_blob_gas.as_ref().map(U256::to_string));
532 l1_gas_prices.push(block.l1_gas_price.as_ref().map(U256::to_string));
533 l1_gas_useds.push(block.l1_gas_used.map(|v| v as i64));
534 l1_fee_scalars.push(block.l1_fee_scalar.map(|v| v as i64));
535 }
536
537 sqlx::query(
539 "
540 INSERT INTO block (
541 chain_id, number, hash, parent_hash, miner, gas_limit, gas_used, timestamp,
542 base_fee_per_gas, blob_gas_used, excess_blob_gas,
543 l1_gas_price, l1_gas_used, l1_fee_scalar
544 )
545 SELECT
546 $1, *
547 FROM UNNEST(
548 $2::int8[], $3::text[], $4::text[], $5::text[],
549 $6::int8[], $7::int8[], $8::text[],
550 $9::text[], $10::text[], $11::text[],
551 $12::text[], $13::int8[], $14::int8[]
552 )
553 ON CONFLICT (chain_id, number) DO NOTHING
554 ",
555 )
556 .bind(chain_id as i32)
557 .bind(&numbers[..])
558 .bind(&hashes[..])
559 .bind(&parent_hashes[..])
560 .bind(&miners[..])
561 .bind(&gas_limits[..])
562 .bind(&gas_useds[..])
563 .bind(×tamps[..])
564 .bind(&base_fee_per_gases as &[Option<String>])
565 .bind(&blob_gas_useds as &[Option<String>])
566 .bind(&excess_blob_gases as &[Option<String>])
567 .bind(&l1_gas_prices as &[Option<String>])
568 .bind(&l1_gas_useds as &[Option<i64>])
569 .bind(&l1_fee_scalars as &[Option<i64>])
570 .execute(&self.pool)
571 .await
572 .map(|_| ())
573 .map_err(|e| anyhow::anyhow!("Failed to batch insert into block table: {e}"))
574 }
575
576 pub async fn add_pool_event_blocks_batch(
582 &self,
583 chain_id: u32,
584 blocks: &[Block],
585 ) -> anyhow::Result<()> {
586 if blocks.is_empty() {
587 return Ok(());
588 }
589
590 let chain_id_db = i32::try_from(chain_id)
591 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
592 let mut numbers: Vec<i64> = Vec::with_capacity(blocks.len());
593 let mut hashes: Vec<String> = Vec::with_capacity(blocks.len());
594 let mut timestamps: Vec<String> = Vec::with_capacity(blocks.len());
595
596 for block in blocks {
597 numbers.push(i64::try_from(block.number).with_context(|| {
598 format!(
599 "Pool event block {} exceeds PostgreSQL BIGINT",
600 block.number
601 )
602 })?);
603 hashes.push(block.hash.clone());
604 timestamps.push(block.timestamp.to_string());
605 }
606
607 sqlx::query(
608 "
609 INSERT INTO pool_event_block (
610 chain_id, number, hash, timestamp
611 )
612 SELECT
613 $1, *
614 FROM UNNEST(
615 $2::int8[], $3::text[], $4::text[]
616 )
617 ON CONFLICT (chain_id, number)
618 DO UPDATE SET hash = EXCLUDED.hash, timestamp = EXCLUDED.timestamp
619 ",
620 )
621 .bind(chain_id_db)
622 .bind(&numbers[..])
623 .bind(&hashes[..])
624 .bind(×tamps[..])
625 .execute(&self.pool)
626 .await
627 .map(|_| ())
628 .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_event_block table: {e}"))
629 }
630
631 pub async fn ensure_pool_event_block_hash_schema(&self) -> anyhow::Result<()> {
637 sqlx::query("ALTER TABLE pool_event_block ADD COLUMN IF NOT EXISTS hash TEXT")
638 .execute(&self.pool)
639 .await
640 .map(|_| ())
641 .map_err(|e| anyhow::anyhow!("Failed to add pool event block hash storage: {e}"))
642 }
643
644 pub async fn add_blocks_copy(&self, chain_id: u32, blocks: &[Block]) -> anyhow::Result<()> {
653 let copy_handler = PostgresCopyHandler::new(&self.pool);
654 copy_handler.copy_blocks(chain_id, blocks).await
655 }
656
657 pub async fn add_tokens_copy(&self, chain_id: u32, tokens: &[Token]) -> anyhow::Result<()> {
663 let copy_handler = PostgresCopyHandler::new(&self.pool);
664 copy_handler.copy_tokens(chain_id, tokens).await
665 }
666
667 pub async fn add_pools_copy(&self, chain_id: u32, pools: &[Pool]) -> anyhow::Result<()> {
673 let copy_handler = PostgresCopyHandler::new(&self.pool);
674 copy_handler.copy_pools(chain_id, pools).await
675 }
676
677 pub async fn add_pool_swaps_copy(
686 &self,
687 chain_id: u32,
688 swaps: &[PoolSwap],
689 ) -> anyhow::Result<()> {
690 let copy_handler = PostgresCopyHandler::new(&self.pool);
691 copy_handler.copy_pool_swaps(chain_id, swaps).await
692 }
693
694 pub async fn add_pool_liquidity_updates_copy(
703 &self,
704 chain_id: u32,
705 updates: &[PoolLiquidityUpdate],
706 ) -> anyhow::Result<()> {
707 let copy_handler = PostgresCopyHandler::new(&self.pool);
708 copy_handler
709 .copy_pool_liquidity_updates(chain_id, updates)
710 .await
711 }
712
713 pub async fn copy_pool_fee_collects_batch(
722 &self,
723 chain_id: u32,
724 collects: &[PoolFeeCollect],
725 ) -> anyhow::Result<()> {
726 let copy_handler = PostgresCopyHandler::new(&self.pool);
727 copy_handler.copy_pool_collects(chain_id, collects).await
728 }
729
730 pub async fn load_block_timestamps(
736 &self,
737 chain: SharedChain,
738 from_block: u64,
739 ) -> anyhow::Result<Vec<BlockTimestampRow>> {
740 sqlx::query_as::<_, BlockTimestampRow>(
741 "
742 SELECT DISTINCT ON (number)
743 number,
744 timestamp
745 FROM (
746 SELECT number, timestamp, 0 AS source_order
747 FROM block
748 WHERE chain_id = $1 AND number >= $2 AND timestamp IS NOT NULL
749 UNION ALL
750 SELECT number, timestamp, 1 AS source_order
751 FROM pool_event_block
752 WHERE chain_id = $1 AND number >= $2 AND timestamp IS NOT NULL
753 ) AS block_timestamps
754 ORDER BY number ASC, source_order ASC
755 ",
756 )
757 .bind(chain.chain_id as i32)
758 .bind(from_block as i64)
759 .fetch_all(&self.pool)
760 .await
761 .map_err(|e| anyhow::anyhow!("Failed to load block timestamps: {e}"))
762 }
763
764 pub async fn add_dex(&self, dex: SharedDex) -> anyhow::Result<()> {
770 sqlx::query(
771 "
772 INSERT INTO dex (
773 chain_id, name, factory_address, creation_block
774 ) VALUES ($1, $2, $3, $4)
775 ON CONFLICT (chain_id, name)
776 DO UPDATE
777 SET
778 factory_address = $3,
779 creation_block = $4
780 ",
781 )
782 .bind(dex.chain.chain_id as i32)
783 .bind(dex.name.to_string())
784 .bind(dex.factory.to_string())
785 .bind(dex.factory_creation_block as i64)
786 .execute(&self.pool)
787 .await
788 .map(|_| ())
789 .map_err(|e| anyhow::anyhow!("Failed to insert into dex table: {e}"))
790 }
791
792 pub async fn add_pool(&self, pool: &Pool) -> anyhow::Result<()> {
798 sqlx::query(
799 "
800 INSERT INTO pool (
801 chain_id, address, pool_identifier, dex_name, creation_block,
802 token0_chain, token0_address,
803 token1_chain, token1_address,
804 fee, tick_spacing, initial_tick, initial_sqrt_price_x96, hook_address
805 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
806 ON CONFLICT (chain_id, dex_name, pool_identifier)
807 DO UPDATE
808 SET
809 address = $2,
810 creation_block = $5,
811 token0_chain = $6,
812 token0_address = $7,
813 token1_chain = $8,
814 token1_address = $9,
815 fee = $10,
816 tick_spacing = $11,
817 initial_tick = $12,
818 initial_sqrt_price_x96 = $13,
819 hook_address = $14
820 ",
821 )
822 .bind(pool.chain.chain_id as i32)
823 .bind(pool.address.to_string())
824 .bind(pool.pool_identifier.as_ref())
825 .bind(pool.dex.name.to_string())
826 .bind(pool.creation_block as i64)
827 .bind(pool.token0.chain.chain_id as i32)
828 .bind(pool.token0.address.to_string())
829 .bind(pool.token1.chain.chain_id as i32)
830 .bind(pool.token1.address.to_string())
831 .bind(pool.fee.map(|fee| fee as i32))
832 .bind(pool.tick_spacing.map(|tick_spacing| tick_spacing as i32))
833 .bind(pool.initial_tick)
834 .bind(pool.initial_sqrt_price_x96.as_ref().map(|p| p.to_string()))
835 .bind(pool.hooks.as_ref().map(|h| h.to_string()))
836 .execute(&self.pool)
837 .await
838 .map(|_| ())
839 .map_err(|e| anyhow::anyhow!("Failed to insert into pool table: {e}"))
840 }
841
842 pub async fn add_pools_batch(&self, pools: &[Pool]) -> anyhow::Result<()> {
848 if pools.is_empty() {
849 return Ok(());
850 }
851
852 let len = pools.len();
854 let mut addresses: Vec<String> = Vec::with_capacity(len);
855 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
856 let mut dex_names: Vec<String> = Vec::with_capacity(len);
857 let mut creation_blocks: Vec<i64> = Vec::with_capacity(len);
858 let mut token0_chains: Vec<i32> = Vec::with_capacity(len);
859 let mut token0_addresses: Vec<String> = Vec::with_capacity(len);
860 let mut token1_chains: Vec<i32> = Vec::with_capacity(len);
861 let mut token1_addresses: Vec<String> = Vec::with_capacity(len);
862 let mut fees: Vec<Option<i32>> = Vec::with_capacity(len);
863 let mut tick_spacings: Vec<Option<i32>> = Vec::with_capacity(len);
864 let mut initial_ticks: Vec<Option<i32>> = Vec::with_capacity(len);
865 let mut initial_sqrt_price_x96s: Vec<Option<String>> = Vec::with_capacity(len);
866 let mut hook_addresses: Vec<Option<String>> = Vec::with_capacity(len);
867 let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
868
869 for pool in pools {
871 chain_ids.push(pool.chain.chain_id as i32);
872 addresses.push(pool.address.to_string());
873 pool_identifiers.push(pool.pool_identifier.to_string());
874 dex_names.push(pool.dex.name.to_string());
875 creation_blocks.push(pool.creation_block as i64);
876 token0_chains.push(pool.token0.chain.chain_id as i32);
877 token0_addresses.push(pool.token0.address.to_string());
878 token1_chains.push(pool.token1.chain.chain_id as i32);
879 token1_addresses.push(pool.token1.address.to_string());
880 fees.push(pool.fee.map(|fee| fee as i32));
881 tick_spacings.push(pool.tick_spacing.map(|tick_spacing| tick_spacing as i32));
882 initial_ticks.push(pool.initial_tick);
883 initial_sqrt_price_x96s
884 .push(pool.initial_sqrt_price_x96.as_ref().map(|p| p.to_string()));
885 hook_addresses.push(pool.hooks.as_ref().map(|h| h.to_string()));
886 }
887
888 sqlx::query(
890 "
891 INSERT INTO pool (
892 chain_id, address, pool_identifier, dex_name, creation_block,
893 token0_chain, token0_address,
894 token1_chain, token1_address,
895 fee, tick_spacing, initial_tick, initial_sqrt_price_x96, hook_address
896 )
897 SELECT *
898 FROM UNNEST(
899 $1::int4[], $2::text[], $3::text[], $4::text[], $5::int8[],
900 $6::int4[], $7::text[], $8::int4[], $9::text[],
901 $10::int4[], $11::int4[], $12::int4[], $13::text[], $14::text[]
902 )
903 ON CONFLICT (chain_id, dex_name, pool_identifier) DO NOTHING
904 ",
905 )
906 .bind(&chain_ids[..])
907 .bind(&addresses[..])
908 .bind(&pool_identifiers[..])
909 .bind(&dex_names[..])
910 .bind(&creation_blocks[..])
911 .bind(&token0_chains[..])
912 .bind(&token0_addresses[..])
913 .bind(&token1_chains[..])
914 .bind(&token1_addresses[..])
915 .bind(&fees[..])
916 .bind(&tick_spacings[..])
917 .bind(&initial_ticks[..])
918 .bind(&initial_sqrt_price_x96s[..])
919 .bind(&hook_addresses as &[Option<String>])
920 .execute(&self.pool)
921 .await
922 .map(|_| ())
923 .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool table: {e}"))
924 }
925
926 pub async fn add_pool_swaps_batch(
932 &self,
933 chain_id: u32,
934 swaps: &[PoolSwap],
935 ) -> anyhow::Result<()> {
936 if swaps.is_empty() {
937 return Ok(());
938 }
939
940 let len = swaps.len();
942 let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
943 let mut dex_names: Vec<String> = Vec::with_capacity(len);
944 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
945 let mut blocks: Vec<i64> = Vec::with_capacity(len);
946 let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
947 let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
948 let mut log_indices: Vec<i32> = Vec::with_capacity(len);
949 let mut senders: Vec<String> = Vec::with_capacity(len);
950 let mut recipients: Vec<String> = Vec::with_capacity(len);
951 let mut sqrt_price_x96s: Vec<String> = Vec::with_capacity(len);
952 let mut liquidities: Vec<String> = Vec::with_capacity(len);
953 let mut ticks: Vec<i32> = Vec::with_capacity(len);
954 let mut amount0s: Vec<String> = Vec::with_capacity(len);
955 let mut amount1s: Vec<String> = Vec::with_capacity(len);
956 let mut order_sides: Vec<Option<String>> = Vec::with_capacity(len);
957 let mut base_quantities: Vec<Option<Decimal>> = Vec::with_capacity(len);
958 let mut quote_quantities: Vec<Option<Decimal>> = Vec::with_capacity(len);
959 let mut spot_prices: Vec<Option<Decimal>> = Vec::with_capacity(len);
960 let mut execution_prices: Vec<Option<Decimal>> = Vec::with_capacity(len);
961
962 for swap in swaps {
964 chain_ids.push(chain_id as i32);
965 dex_names.push(swap.dex.name.to_string());
966 pool_identifiers.push(swap.pool_identifier.to_string());
967 blocks.push(swap.block as i64);
968 transaction_hashes.push(swap.transaction_hash.clone());
969 transaction_indices.push(swap.transaction_index as i32);
970 log_indices.push(swap.log_index as i32);
971 senders.push(swap.sender.to_string());
972 recipients.push(swap.recipient.to_string());
973 sqrt_price_x96s.push(swap.sqrt_price_x96.to_string());
974 liquidities.push(swap.liquidity.to_string());
975 ticks.push(swap.tick);
976 amount0s.push(swap.amount0.to_string());
977 amount1s.push(swap.amount1.to_string());
978
979 if let Some(ref trade_info) = swap.trade_info {
981 order_sides.push(Some(trade_info.order_side.to_string()));
982 base_quantities.push(Some(trade_info.quantity_base.as_decimal()));
983 quote_quantities.push(Some(trade_info.quantity_quote.as_decimal()));
984 spot_prices.push(Some(trade_info.spot_price.as_decimal()));
985 execution_prices.push(Some(trade_info.execution_price.as_decimal()));
986 } else {
987 order_sides.push(None);
988 base_quantities.push(None);
989 quote_quantities.push(None);
990 spot_prices.push(None);
991 execution_prices.push(None);
992 }
993 }
994
995 sqlx::query(
997 "
998 INSERT INTO pool_swap_event (
999 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1000 log_index, sender, recipient, sqrt_price_x96, liquidity, tick, amount0, amount1,
1001 order_side, base_quantity, quote_quantity, spot_price, execution_price
1002 )
1003 SELECT
1004 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index, log_index, sender, recipient,
1005 sqrt_price_x96::U160, liquidity::U128, tick, amount0::I256, amount1::I256,
1006 order_side, base_quantity, quote_quantity, spot_price, execution_price
1007 FROM UNNEST(
1008 $1::INT[], $2::TEXT[], $3::TEXT[], $4::BIGINT[], $5::TEXT[], $6::INT[], $7::INT[],
1009 $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::TEXT[], $12::INT[], $13::TEXT[], $14::TEXT[],
1010 $15::TEXT[], $16, $17, $18, $19
1011 ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1012 log_index, sender, recipient, sqrt_price_x96, liquidity, tick, amount0, amount1,
1013 order_side, base_quantity, quote_quantity, spot_price, execution_price)
1014 ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
1015 ",
1016 )
1017 .bind(&chain_ids[..])
1018 .bind(&dex_names[..])
1019 .bind(&pool_identifiers[..])
1020 .bind(&blocks[..])
1021 .bind(&transaction_hashes[..])
1022 .bind(&transaction_indices[..])
1023 .bind(&log_indices[..])
1024 .bind(&senders[..])
1025 .bind(&recipients[..])
1026 .bind(&sqrt_price_x96s[..])
1027 .bind(&liquidities[..])
1028 .bind(&ticks[..])
1029 .bind(&amount0s[..])
1030 .bind(&amount1s[..])
1031 .bind(&order_sides[..])
1032 .bind(&base_quantities[..])
1033 .bind("e_quantities[..])
1034 .bind(&spot_prices[..])
1035 .bind(&execution_prices[..])
1036 .execute(&self.pool)
1037 .await
1038 .map(|_| ())
1039 .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_swap_event table: {e}"))
1040 }
1041
1042 pub async fn add_pool_liquidity_updates_batch(
1048 &self,
1049 chain_id: u32,
1050 updates: &[PoolLiquidityUpdate],
1051 ) -> anyhow::Result<()> {
1052 if updates.is_empty() {
1053 return Ok(());
1054 }
1055
1056 let len = updates.len();
1058 let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1059 let mut dex_names: Vec<String> = Vec::with_capacity(len);
1060 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1061 let mut blocks: Vec<i64> = Vec::with_capacity(len);
1062 let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1063 let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1064 let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1065 let mut event_types: Vec<String> = Vec::with_capacity(len);
1066 let mut senders: Vec<Option<String>> = Vec::with_capacity(len);
1067 let mut owners: Vec<String> = Vec::with_capacity(len);
1068 let mut position_liquidities: Vec<String> = Vec::with_capacity(len);
1069 let mut amount0s: Vec<String> = Vec::with_capacity(len);
1070 let mut amount1s: Vec<String> = Vec::with_capacity(len);
1071 let mut tick_lowers: Vec<i32> = Vec::with_capacity(len);
1072 let mut tick_uppers: Vec<i32> = Vec::with_capacity(len);
1073
1074 for update in updates {
1076 chain_ids.push(chain_id as i32);
1077 dex_names.push(update.dex.name.to_string());
1078 pool_identifiers.push(update.pool_identifier.to_string());
1079 blocks.push(update.block as i64);
1080 transaction_hashes.push(update.transaction_hash.clone());
1081 transaction_indices.push(update.transaction_index as i32);
1082 log_indices.push(update.log_index as i32);
1083 event_types.push(update.kind.to_string());
1084 senders.push(update.sender.map(|s| s.to_string()));
1085 owners.push(update.owner.to_string());
1086 position_liquidities.push(update.position_liquidity.to_string());
1087 amount0s.push(update.amount0.to_string());
1088 amount1s.push(update.amount1.to_string());
1089 tick_lowers.push(update.tick_lower);
1090 tick_uppers.push(update.tick_upper);
1091 }
1092
1093 sqlx::query(
1095 "
1096 INSERT INTO pool_liquidity_event (
1097 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1098 log_index, event_type, sender, owner, position_liquidity,
1099 amount0, amount1, tick_lower, tick_upper
1100 )
1101 SELECT
1102 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1103 log_index, event_type, sender, owner, position_liquidity::u128,
1104 amount0::U256, amount1::U256, tick_lower, tick_upper
1105 FROM UNNEST(
1106 $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
1107 $7::INT[], $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::TEXT[],
1108 $12::TEXT[], $13::TEXT[], $14::INT[], $15::INT[]
1109 ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1110 log_index, event_type, sender, owner, position_liquidity,
1111 amount0, amount1, tick_lower, tick_upper)
1112 ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
1113 ",
1114 )
1115 .bind(&chain_ids[..])
1116 .bind(&dex_names[..])
1117 .bind(&pool_identifiers[..])
1118 .bind(&blocks[..])
1119 .bind(&transaction_hashes[..])
1120 .bind(&transaction_indices[..])
1121 .bind(&log_indices[..])
1122 .bind(&event_types[..])
1123 .bind(&senders[..])
1124 .bind(&owners[..])
1125 .bind(&position_liquidities[..])
1126 .bind(&amount0s[..])
1127 .bind(&amount1s[..])
1128 .bind(&tick_lowers[..])
1129 .bind(&tick_uppers[..])
1130 .execute(&self.pool)
1131 .await
1132 .map(|_| ())
1133 .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_liquidity_event table: {e}"))
1134 }
1135
1136 pub async fn add_token(&self, token: &Token) -> anyhow::Result<()> {
1142 sqlx::query(
1143 "
1144 INSERT INTO token (
1145 chain_id, address, name, symbol, decimals
1146 ) VALUES ($1, $2, $3, $4, $5)
1147 ON CONFLICT (chain_id, address)
1148 DO UPDATE
1149 SET
1150 name = $3,
1151 symbol = $4,
1152 decimals = $5
1153 ",
1154 )
1155 .bind(token.chain.chain_id as i32)
1156 .bind(token.address.to_string())
1157 .bind(token.name.as_str())
1158 .bind(token.symbol.as_str())
1159 .bind(i32::from(token.decimals))
1160 .execute(&self.pool)
1161 .await
1162 .map(|_| ())
1163 .map_err(|e| anyhow::anyhow!("Failed to insert into token table: {e}"))
1164 }
1165
1166 pub async fn add_invalid_token(
1172 &self,
1173 chain_id: u32,
1174 address: &Address,
1175 error_string: &str,
1176 ) -> anyhow::Result<()> {
1177 sqlx::query(
1178 "
1179 INSERT INTO token (
1180 chain_id, address, error
1181 ) VALUES ($1, $2, $3)
1182 ON CONFLICT (chain_id, address)
1183 DO NOTHING;
1184 ",
1185 )
1186 .bind(chain_id as i32)
1187 .bind(address.to_string())
1188 .bind(error_string)
1189 .execute(&self.pool)
1190 .await
1191 .map(|_| ())
1192 .map_err(|e| anyhow::anyhow!("Failed to insert into token table: {e}"))
1193 }
1194
1195 pub async fn add_swap(&self, chain_id: u32, swap: &PoolSwap) -> anyhow::Result<()> {
1201 let (order_side, base_quantity, quote_quantity, spot_price, execution_price) =
1203 if let Some(ref trade_info) = swap.trade_info {
1204 (
1205 Some(trade_info.order_side.to_string()),
1206 Some(trade_info.quantity_base.as_decimal()),
1207 Some(trade_info.quantity_quote.as_decimal()),
1208 Some(trade_info.spot_price.as_decimal()),
1209 Some(trade_info.execution_price.as_decimal()),
1210 )
1211 } else {
1212 (None, None, None, None, None)
1213 };
1214
1215 sqlx::query(
1216 "
1217 INSERT INTO pool_swap_event (
1218 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1219 log_index, sender, recipient, sqrt_price_x96, liquidity, tick, amount0, amount1,
1220 order_side, base_quantity, quote_quantity, spot_price, execution_price
1221 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::U160, $11::U128, $12, $13::I256, $14::I256, $15, $16, $17, $18, $19)
1222 ON CONFLICT (chain_id, transaction_hash, log_index)
1223 DO NOTHING
1224 ",
1225 )
1226 .bind(chain_id as i32)
1227 .bind(swap.dex.name.to_string())
1228 .bind(swap.pool_identifier.as_str())
1229 .bind(swap.block as i64)
1230 .bind(swap.transaction_hash.as_str())
1231 .bind(swap.transaction_index as i32)
1232 .bind(swap.log_index as i32)
1233 .bind(swap.sender.to_string())
1234 .bind(swap.recipient.to_string())
1235 .bind(swap.sqrt_price_x96.to_string())
1236 .bind(swap.liquidity.to_string())
1237 .bind(swap.tick)
1238 .bind(swap.amount0.to_string())
1239 .bind(swap.amount1.to_string())
1240 .bind(order_side)
1241 .bind(base_quantity)
1242 .bind(quote_quantity)
1243 .bind(spot_price)
1244 .bind(execution_price)
1245 .execute(&self.pool)
1246 .await
1247 .map(|_| ())
1248 .map_err(|e| anyhow::anyhow!("Failed to insert into pool_swap table: {e}"))
1249 }
1250
1251 pub async fn add_pool_liquidity_update(
1257 &self,
1258 chain_id: u32,
1259 liquidity_update: &PoolLiquidityUpdate,
1260 ) -> anyhow::Result<()> {
1261 sqlx::query(
1262 "
1263 INSERT INTO pool_liquidity_event (
1264 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index, log_index,
1265 event_type, sender, owner, position_liquidity, amount0, amount1, tick_lower, tick_upper
1266 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
1267 ON CONFLICT (chain_id, transaction_hash, log_index)
1268 DO NOTHING
1269 ",
1270 )
1271 .bind(chain_id as i32)
1272 .bind(liquidity_update.dex.name.to_string())
1273 .bind(liquidity_update.pool_identifier.as_str())
1274 .bind(liquidity_update.block as i64)
1275 .bind(liquidity_update.transaction_hash.as_str())
1276 .bind(liquidity_update.transaction_index as i32)
1277 .bind(liquidity_update.log_index as i32)
1278 .bind(liquidity_update.kind.to_string())
1279 .bind(liquidity_update.sender.map(|sender| sender.to_string()))
1280 .bind(liquidity_update.owner.to_string())
1281 .bind(U128Pg(liquidity_update.position_liquidity))
1282 .bind(U256Pg(liquidity_update.amount0))
1283 .bind(U256Pg(liquidity_update.amount1))
1284 .bind(liquidity_update.tick_lower)
1285 .bind(liquidity_update.tick_upper)
1286 .execute(&self.pool)
1287 .await
1288 .map(|_| ())
1289 .map_err(|e| anyhow::anyhow!("Failed to insert into pool_liquidity table: {e}"))
1290 }
1291
1292 pub async fn load_tokens(&self, chain: SharedChain) -> anyhow::Result<Vec<Token>> {
1301 sqlx::query_as::<_, TokenRow>("SELECT * FROM token WHERE chain_id = $1 AND error IS NULL")
1302 .bind(chain.chain_id as i32)
1303 .fetch_all(&self.pool)
1304 .await
1305 .map(|rows| {
1306 rows.into_iter()
1307 .map(|token_row| {
1308 Token::new(
1309 Arc::clone(&chain),
1310 token_row.address,
1311 token_row.name,
1312 token_row.symbol,
1313 token_row.decimals,
1314 )
1315 })
1316 .collect::<Vec<_>>()
1317 })
1318 .map_err(|e| anyhow::anyhow!("Failed to load tokens: {e}"))
1319 }
1320
1321 pub async fn load_invalid_token_addresses(
1327 &self,
1328 chain_id: u32,
1329 ) -> anyhow::Result<Vec<Address>> {
1330 sqlx::query_as::<_, (String,)>(
1331 "SELECT address FROM token WHERE chain_id = $1 AND error IS NOT NULL",
1332 )
1333 .bind(chain_id as i32)
1334 .fetch_all(&self.pool)
1335 .await?
1336 .into_iter()
1337 .map(|(address,)| validate_address(&address))
1338 .collect::<Result<Vec<_>, _>>()
1339 .map_err(|e| anyhow::anyhow!("Failed to load invalid token addresses: {e}"))
1340 }
1341
1342 pub async fn load_pools(
1348 &self,
1349 chain: SharedChain,
1350 dex_id: &str,
1351 ) -> anyhow::Result<Vec<PoolRow>> {
1352 sqlx::query_as::<_, PoolRow>(AssertSqlSafe(format!(
1353 "SELECT {POOL_ROW_COLUMNS} FROM pool WHERE chain_id = $1 AND dex_name = $2 ORDER BY creation_block ASC"
1354 )))
1355 .bind(chain.chain_id as i32)
1356 .bind(dex_id)
1357 .fetch_all(&self.pool)
1358 .await
1359 .map_err(|e| anyhow::anyhow!("Failed to load pools: {e}"))
1360 }
1361
1362 pub async fn load_pool(
1373 &self,
1374 chain: SharedChain,
1375 dex_id: &str,
1376 pool_identifier: &PoolIdentifier,
1377 ) -> anyhow::Result<Option<PoolRow>> {
1378 sqlx::query_as::<_, PoolRow>(AssertSqlSafe(format!(
1379 "SELECT {POOL_ROW_COLUMNS} FROM pool WHERE chain_id = $1 AND dex_name = $2 AND pool_identifier = $3"
1380 )))
1381 .bind(chain.chain_id as i32)
1382 .bind(dex_id)
1383 .bind(pool_identifier.as_ref())
1384 .fetch_optional(&self.pool)
1385 .await
1386 .map_err(|e| anyhow::anyhow!("Failed to load pool {pool_identifier}: {e}"))
1387 }
1388
1389 pub async fn toggle_perf_sync_settings(&self, enable: bool) -> anyhow::Result<()> {
1403 if enable {
1404 log::debug!("Enabling performance sync settings for bulk operations");
1405
1406 sqlx::query("SET synchronous_commit = OFF")
1408 .execute(&self.pool)
1409 .await
1410 .map_err(|e| anyhow::anyhow!("Failed to set synchronous_commit OFF: {e}"))?;
1411
1412 sqlx::query("SET work_mem = '256MB'")
1414 .execute(&self.pool)
1415 .await
1416 .map_err(|e| anyhow::anyhow!("Failed to set work_mem: {e}"))?;
1417
1418 log::debug!("Performance settings enabled: synchronous_commit=OFF, work_mem=256MB");
1419 } else {
1420 log::debug!("Restoring default safe database performance settings");
1421
1422 sqlx::query("SET synchronous_commit = ON")
1424 .execute(&self.pool)
1425 .await
1426 .map_err(|e| anyhow::anyhow!("Failed to set synchronous_commit ON: {e}"))?;
1427
1428 sqlx::query("RESET work_mem")
1430 .execute(&self.pool)
1431 .await
1432 .map_err(|e| anyhow::anyhow!("Failed to reset work_mem: {e}"))?;
1433 }
1434
1435 Ok(())
1436 }
1437
1438 pub async fn update_dex_last_synced_block(
1444 &self,
1445 chain_id: u32,
1446 dex: &DexType,
1447 block_number: u64,
1448 ) -> anyhow::Result<()> {
1449 sqlx::query(
1450 "
1451 UPDATE dex
1452 SET last_full_sync_pools_block_number = $3
1453 WHERE chain_id = $1 AND name = $2
1454 ",
1455 )
1456 .bind(chain_id as i32)
1457 .bind(dex.to_string())
1458 .bind(block_number as i64)
1459 .execute(&self.pool)
1460 .await
1461 .map(|_| ())
1462 .map_err(|e| anyhow::anyhow!("Failed to update dex last synced block: {e}"))
1463 }
1464
1465 pub async fn update_pool_last_synced_block(
1471 &self,
1472 chain_id: u32,
1473 dex: &DexType,
1474 pool_identifier: &PoolIdentifier,
1475 block_number: u64,
1476 ) -> anyhow::Result<()> {
1477 sqlx::query(
1478 "
1479 UPDATE pool
1480 SET last_full_sync_block_number = $4
1481 WHERE chain_id = $1
1482 AND dex_name = $2
1483 AND pool_identifier = $3
1484 ",
1485 )
1486 .bind(chain_id as i32)
1487 .bind(dex.to_string())
1488 .bind(pool_identifier.as_ref())
1489 .bind(block_number as i64)
1490 .execute(&self.pool)
1491 .await
1492 .map(|_| ())
1493 .map_err(|e| anyhow::anyhow!("Failed to update pool last synced block: {e}"))
1494 }
1495
1496 pub async fn get_dex_last_synced_block(
1502 &self,
1503 chain_id: u32,
1504 dex: &DexType,
1505 ) -> anyhow::Result<Option<u64>> {
1506 let result = sqlx::query_as::<_, (Option<i64>,)>(
1507 "
1508 SELECT
1509 last_full_sync_pools_block_number
1510 FROM dex
1511 WHERE chain_id = $1
1512 AND name = $2
1513 ",
1514 )
1515 .bind(chain_id as i32)
1516 .bind(dex.to_string())
1517 .fetch_optional(&self.pool)
1518 .await
1519 .map_err(|e| anyhow::anyhow!("Failed to get dex last synced block: {e}"))?;
1520
1521 Ok(result.and_then(|(block_number,)| block_number.map(|b| b as u64)))
1522 }
1523
1524 pub async fn get_pool_last_synced_block(
1530 &self,
1531 chain_id: u32,
1532 dex: &DexType,
1533 pool_identifier: &PoolIdentifier,
1534 ) -> anyhow::Result<Option<u64>> {
1535 let result = sqlx::query_as::<_, (Option<i64>,)>(
1536 "
1537 SELECT
1538 last_full_sync_block_number
1539 FROM pool
1540 WHERE chain_id = $1
1541 AND dex_name = $2
1542 AND pool_identifier = $3
1543 ",
1544 )
1545 .bind(chain_id as i32)
1546 .bind(dex.to_string())
1547 .bind(pool_identifier.as_ref())
1548 .fetch_optional(&self.pool)
1549 .await
1550 .map_err(|e| anyhow::anyhow!("Failed to get pool last synced block: {e}"))?;
1551
1552 Ok(result.and_then(|(block_number,)| block_number.map(|b| b as u64)))
1553 }
1554
1555 pub(crate) async fn get_pool_event_sync_state(
1556 &self,
1557 chain_id: u32,
1558 dex: &DexType,
1559 pool_identifier: &PoolIdentifier,
1560 ) -> anyhow::Result<PoolEventSyncState> {
1561 let pool_state = sqlx::query_as::<_, (i32, Option<i64>)>(
1562 "
1563 SELECT event_sync_version, last_full_sync_block_number
1564 FROM pool
1565 WHERE chain_id = $1
1566 AND dex_name = $2
1567 AND pool_identifier = $3
1568 ",
1569 )
1570 .bind(chain_id as i32)
1571 .bind(dex.to_string())
1572 .bind(pool_identifier.as_ref())
1573 .fetch_optional(&self.pool)
1574 .await
1575 .map_err(|e| anyhow::anyhow!("Failed to get pool event sync state: {e}"))?;
1576
1577 let Some((version, last_full_sync_block)) = pool_state else {
1578 return Ok(PoolEventSyncState::default());
1579 };
1580
1581 let family_blocks = sqlx::query_as::<_, (String, i64)>(
1582 "
1583 SELECT event_family, last_full_sync_block_number
1584 FROM pool_event_sync
1585 WHERE chain_id = $1
1586 AND dex_name = $2
1587 AND pool_identifier = $3
1588 ORDER BY event_family
1589 ",
1590 )
1591 .bind(chain_id as i32)
1592 .bind(dex.to_string())
1593 .bind(pool_identifier.as_ref())
1594 .fetch_all(&self.pool)
1595 .await
1596 .map_err(|e| anyhow::anyhow!("Failed to get pool event-family checkpoints: {e}"))?
1597 .into_iter()
1598 .map(|(family, block)| (family, block as u64))
1599 .collect();
1600
1601 Ok(PoolEventSyncState {
1602 version: version as u32,
1603 last_full_sync_block: last_full_sync_block.map(|block| block as u64),
1604 family_blocks,
1605 })
1606 }
1607
1608 pub(crate) async fn update_pool_event_sync(
1609 &self,
1610 chain_id: u32,
1611 dex: &DexType,
1612 pool_identifier: &PoolIdentifier,
1613 event_families: &[&str],
1614 block_number: u64,
1615 version: Option<u32>,
1616 ) -> anyhow::Result<()> {
1617 let mut transaction = self.pool.begin().await?;
1618
1619 for event_family in event_families {
1620 sqlx::query(
1621 "
1622 INSERT INTO pool_event_sync (
1623 chain_id, dex_name, pool_identifier, event_family,
1624 last_full_sync_block_number
1625 )
1626 VALUES ($1, $2, $3, $4, $5)
1627 ON CONFLICT (chain_id, dex_name, pool_identifier, event_family)
1628 DO UPDATE SET last_full_sync_block_number =
1629 GREATEST(
1630 pool_event_sync.last_full_sync_block_number,
1631 EXCLUDED.last_full_sync_block_number
1632 )
1633 ",
1634 )
1635 .bind(chain_id as i32)
1636 .bind(dex.to_string())
1637 .bind(pool_identifier.as_ref())
1638 .bind(event_family)
1639 .bind(block_number as i64)
1640 .execute(&mut *transaction)
1641 .await
1642 .map_err(|e| {
1643 anyhow::anyhow!("Failed to update {event_family} pool event-family checkpoint: {e}")
1644 })?;
1645 }
1646
1647 if let Some(version) = version {
1648 sqlx::query(
1649 "
1650 UPDATE pool
1651 SET
1652 event_sync_version = GREATEST(event_sync_version, $4),
1653 last_full_sync_block_number =
1654 GREATEST(COALESCE(last_full_sync_block_number, $5), $5)
1655 WHERE chain_id = $1
1656 AND dex_name = $2
1657 AND pool_identifier = $3
1658 ",
1659 )
1660 .bind(chain_id as i32)
1661 .bind(dex.to_string())
1662 .bind(pool_identifier.as_ref())
1663 .bind(version as i32)
1664 .bind(block_number as i64)
1665 .execute(&mut *transaction)
1666 .await
1667 .map_err(|e| anyhow::anyhow!("Failed to finalize pool event sync progress: {e}"))?;
1668 }
1669
1670 transaction.commit().await?;
1671 Ok(())
1672 }
1673
1674 pub async fn get_table_last_block(
1681 &self,
1682 chain_id: u32,
1683 table_name: &str,
1684 pool_identifier: &PoolIdentifier,
1685 ) -> anyhow::Result<Option<u64>> {
1686 let query = format!(
1687 "SELECT MAX(block) FROM {table_name} WHERE chain_id = $1 AND pool_identifier = $2"
1688 );
1689 let result = sqlx::query_as::<_, (Option<i64>,)>(AssertSqlSafe(query))
1690 .bind(chain_id as i32)
1691 .bind(pool_identifier.as_ref())
1692 .fetch_optional(&self.pool)
1693 .await
1694 .map_err(|e| anyhow::anyhow!("Failed to get table last block for {table_name}: {e}"))?;
1695
1696 Ok(result.and_then(|(block_number,)| block_number.map(|b| b as u64)))
1697 }
1698
1699 pub async fn add_pool_collects_batch(
1705 &self,
1706 chain_id: u32,
1707 collects: &[PoolFeeCollect],
1708 ) -> anyhow::Result<()> {
1709 if collects.is_empty() {
1710 return Ok(());
1711 }
1712
1713 let len = collects.len();
1715 let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1716 let mut dex_names: Vec<String> = Vec::with_capacity(len);
1717 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1718 let mut blocks: Vec<i64> = Vec::with_capacity(len);
1719 let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1720 let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1721 let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1722 let mut owners: Vec<String> = Vec::with_capacity(len);
1723 let mut amount0s: Vec<String> = Vec::with_capacity(len);
1724 let mut amount1s: Vec<String> = Vec::with_capacity(len);
1725 let mut tick_lowers: Vec<i32> = Vec::with_capacity(len);
1726 let mut tick_uppers: Vec<i32> = Vec::with_capacity(len);
1727
1728 for collect in collects {
1730 chain_ids.push(chain_id as i32);
1731 dex_names.push(collect.dex.name.to_string());
1732 pool_identifiers.push(collect.pool_identifier.to_string());
1733 blocks.push(collect.block as i64);
1734 transaction_hashes.push(collect.transaction_hash.clone());
1735 transaction_indices.push(collect.transaction_index as i32);
1736 log_indices.push(collect.log_index as i32);
1737 owners.push(collect.owner.to_string());
1738 amount0s.push(collect.amount0.to_string());
1739 amount1s.push(collect.amount1.to_string());
1740 tick_lowers.push(collect.tick_lower);
1741 tick_uppers.push(collect.tick_upper);
1742 }
1743
1744 sqlx::query(
1746 "
1747 INSERT INTO pool_collect_event (
1748 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1749 log_index, owner, amount0, amount1, tick_lower, tick_upper
1750 )
1751 SELECT
1752 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1753 log_index, owner, amount0::U256, amount1::U256, tick_lower, tick_upper
1754 FROM UNNEST(
1755 $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
1756 $7::INT[], $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::INT[], $12::INT[]
1757 ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1758 log_index, owner, amount0, amount1, tick_lower, tick_upper)
1759 ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
1760 ",
1761 )
1762 .bind(&chain_ids[..])
1763 .bind(&dex_names[..])
1764 .bind(&pool_identifiers[..])
1765 .bind(&blocks[..])
1766 .bind(&transaction_hashes[..])
1767 .bind(&transaction_indices[..])
1768 .bind(&log_indices[..])
1769 .bind(&owners[..])
1770 .bind(&amount0s[..])
1771 .bind(&amount1s[..])
1772 .bind(&tick_lowers[..])
1773 .bind(&tick_uppers[..])
1774 .execute(&self.pool)
1775 .await
1776 .map(|_| ())
1777 .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_fee_collect table: {e}"))
1778 }
1779
1780 pub async fn add_pool_flash_batch(
1786 &self,
1787 chain_id: u32,
1788 flash_events: &[PoolFlash],
1789 ) -> anyhow::Result<()> {
1790 if flash_events.is_empty() {
1791 return Ok(());
1792 }
1793
1794 let len = flash_events.len();
1796 let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1797 let mut dex_names: Vec<String> = Vec::with_capacity(len);
1798 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1799 let mut blocks: Vec<i64> = Vec::with_capacity(len);
1800 let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1801 let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1802 let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1803 let mut senders: Vec<String> = Vec::with_capacity(len);
1804 let mut recipients: Vec<String> = Vec::with_capacity(len);
1805 let mut amount0s: Vec<String> = Vec::with_capacity(len);
1806 let mut amount1s: Vec<String> = Vec::with_capacity(len);
1807 let mut paid0s: Vec<String> = Vec::with_capacity(len);
1808 let mut paid1s: Vec<String> = Vec::with_capacity(len);
1809
1810 for flash in flash_events {
1812 chain_ids.push(chain_id as i32);
1813 dex_names.push(flash.dex.name.to_string());
1814 pool_identifiers.push(flash.pool_identifier.to_string());
1815 blocks.push(flash.block as i64);
1816 transaction_hashes.push(flash.transaction_hash.clone());
1817 transaction_indices.push(flash.transaction_index as i32);
1818 log_indices.push(flash.log_index as i32);
1819 senders.push(flash.sender.to_string());
1820 recipients.push(flash.recipient.to_string());
1821 amount0s.push(flash.amount0.to_string());
1822 amount1s.push(flash.amount1.to_string());
1823 paid0s.push(flash.paid0.to_string());
1824 paid1s.push(flash.paid1.to_string());
1825 }
1826
1827 sqlx::query(
1829 "
1830 INSERT INTO pool_flash_event (
1831 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1832 log_index, sender, recipient, amount0, amount1, paid0, paid1
1833 )
1834 SELECT
1835 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1836 log_index, sender, recipient, amount0::U256, amount1::U256, paid0::U256, paid1::U256
1837 FROM UNNEST(
1838 $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
1839 $7::INT[], $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::TEXT[], $12::TEXT[], $13::TEXT[]
1840 ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1841 log_index, sender, recipient, amount0, amount1, paid0, paid1)
1842 ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
1843 ",
1844 )
1845 .bind(&chain_ids[..])
1846 .bind(&dex_names[..])
1847 .bind(&pool_identifiers[..])
1848 .bind(&blocks[..])
1849 .bind(&transaction_hashes[..])
1850 .bind(&transaction_indices[..])
1851 .bind(&log_indices[..])
1852 .bind(&senders[..])
1853 .bind(&recipients[..])
1854 .bind(&amount0s[..])
1855 .bind(&amount1s[..])
1856 .bind(&paid0s[..])
1857 .bind(&paid1s[..])
1858 .execute(&self.pool)
1859 .await
1860 .map(|_| ())
1861 .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_flash_event table: {e}"))
1862 }
1863
1864 pub async fn add_pool_fee_protocol_updates_batch(
1870 &self,
1871 chain_id: u32,
1872 updates: &[PoolFeeProtocolUpdate],
1873 ) -> anyhow::Result<()> {
1874 if updates.is_empty() {
1875 return Ok(());
1876 }
1877
1878 let len = updates.len();
1880 let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1881 let mut dex_names: Vec<String> = Vec::with_capacity(len);
1882 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1883 let mut blocks: Vec<i64> = Vec::with_capacity(len);
1884 let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1885 let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1886 let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1887 let mut fee_protocol0s: Vec<i32> = Vec::with_capacity(len);
1888 let mut fee_protocol1s: Vec<i32> = Vec::with_capacity(len);
1889
1890 for update in updates {
1892 chain_ids.push(chain_id as i32);
1893 dex_names.push(update.dex.name.to_string());
1894 pool_identifiers.push(update.pool_identifier.to_string());
1895 blocks.push(update.block as i64);
1896 transaction_hashes.push(update.transaction_hash.clone());
1897 transaction_indices.push(update.transaction_index as i32);
1898 log_indices.push(update.log_index as i32);
1899 fee_protocol0s.push(i32::try_from(update.fee_protocol0_new).map_err(|e| {
1900 anyhow::anyhow!(
1901 "Invalid fee_protocol0_new '{}': {e}",
1902 update.fee_protocol0_new
1903 )
1904 })?);
1905 fee_protocol1s.push(i32::try_from(update.fee_protocol1_new).map_err(|e| {
1906 anyhow::anyhow!(
1907 "Invalid fee_protocol1_new '{}': {e}",
1908 update.fee_protocol1_new
1909 )
1910 })?);
1911 }
1912
1913 sqlx::query(
1915 "
1916 INSERT INTO pool_fee_protocol_update_event (
1917 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1918 log_index, fee_protocol0_new, fee_protocol1_new
1919 )
1920 SELECT
1921 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1922 log_index, fee_protocol0_new, fee_protocol1_new
1923 FROM UNNEST(
1924 $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
1925 $7::INT[], $8::INT[], $9::INT[]
1926 ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1927 log_index, fee_protocol0_new, fee_protocol1_new)
1928 ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
1929 ",
1930 )
1931 .bind(&chain_ids[..])
1932 .bind(&dex_names[..])
1933 .bind(&pool_identifiers[..])
1934 .bind(&blocks[..])
1935 .bind(&transaction_hashes[..])
1936 .bind(&transaction_indices[..])
1937 .bind(&log_indices[..])
1938 .bind(&fee_protocol0s[..])
1939 .bind(&fee_protocol1s[..])
1940 .execute(&self.pool)
1941 .await
1942 .map(|_| ())
1943 .map_err(|e| {
1944 anyhow::anyhow!("Failed to batch insert into pool_fee_protocol_update_event table: {e}")
1945 })
1946 }
1947
1948 pub async fn add_pool_fee_protocol_collect_batch(
1954 &self,
1955 chain_id: u32,
1956 collects: &[PoolFeeProtocolCollect],
1957 ) -> anyhow::Result<()> {
1958 if collects.is_empty() {
1959 return Ok(());
1960 }
1961
1962 let len = collects.len();
1964 let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1965 let mut dex_names: Vec<String> = Vec::with_capacity(len);
1966 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1967 let mut blocks: Vec<i64> = Vec::with_capacity(len);
1968 let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1969 let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1970 let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1971 let mut senders: Vec<String> = Vec::with_capacity(len);
1972 let mut recipients: Vec<String> = Vec::with_capacity(len);
1973 let mut amount0s: Vec<String> = Vec::with_capacity(len);
1974 let mut amount1s: Vec<String> = Vec::with_capacity(len);
1975
1976 for collect in collects {
1978 chain_ids.push(chain_id as i32);
1979 dex_names.push(collect.dex.name.to_string());
1980 pool_identifiers.push(collect.pool_identifier.to_string());
1981 blocks.push(collect.block as i64);
1982 transaction_hashes.push(collect.transaction_hash.clone());
1983 transaction_indices.push(collect.transaction_index as i32);
1984 log_indices.push(collect.log_index as i32);
1985 senders.push(collect.sender.to_string());
1986 recipients.push(collect.recipient.to_string());
1987 amount0s.push(collect.amount0.to_string());
1988 amount1s.push(collect.amount1.to_string());
1989 }
1990
1991 sqlx::query(
1993 "
1994 INSERT INTO pool_fee_protocol_collect_event (
1995 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1996 log_index, sender, recipient, amount0, amount1
1997 )
1998 SELECT
1999 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
2000 log_index, sender, recipient, amount0::U256, amount1::U256
2001 FROM UNNEST(
2002 $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
2003 $7::INT[], $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::TEXT[]
2004 ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
2005 log_index, sender, recipient, amount0, amount1)
2006 ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
2007 ",
2008 )
2009 .bind(&chain_ids[..])
2010 .bind(&dex_names[..])
2011 .bind(&pool_identifiers[..])
2012 .bind(&blocks[..])
2013 .bind(&transaction_hashes[..])
2014 .bind(&transaction_indices[..])
2015 .bind(&log_indices[..])
2016 .bind(&senders[..])
2017 .bind(&recipients[..])
2018 .bind(&amount0s[..])
2019 .bind(&amount1s[..])
2020 .execute(&self.pool)
2021 .await
2022 .map(|_| ())
2023 .map_err(|e| {
2024 anyhow::anyhow!(
2025 "Failed to batch insert into pool_fee_protocol_collect_event table: {e}"
2026 )
2027 })
2028 }
2029
2030 pub async fn add_pool_snapshot(
2036 &self,
2037 chain_id: u32,
2038 dex_name: &DexType,
2039 pool_identifier: &PoolIdentifier,
2040 snapshot: &PoolSnapshot,
2041 ) -> anyhow::Result<()> {
2042 sqlx::query(
2043 "
2044 INSERT INTO pool_snapshot (
2045 chain_id, dex_name, pool_identifier, block, transaction_index, log_index, transaction_hash,
2046 current_tick, price_sqrt_ratio_x96, liquidity,
2047 protocol_fees_token0, protocol_fees_token1, fee_protocol,
2048 fee_protocol0_basis_points, fee_protocol1_basis_points,
2049 fee_growth_global_0, fee_growth_global_1,
2050 total_amount0_deposited, total_amount1_deposited,
2051 total_amount0_collected, total_amount1_collected,
2052 total_swaps, total_mints, total_burns, total_fee_collects, total_flashes,
2053 liquidity_utilization_rate
2054 ) VALUES (
2055 $1, $2, $3, $4, $5, $6, $7,
2056 $8, $9::U160, $10::U128, $11::U256, $12::U256, $13, $14, $15,
2057 $16::U256, $17::U256, $18::U256, $19::U256, $20::U256, $21::U256,
2058 $22, $23, $24, $25, $26, $27
2059 )
2060 ON CONFLICT (chain_id, pool_identifier, block, transaction_index, log_index)
2061 DO UPDATE SET
2062 dex_name = EXCLUDED.dex_name,
2063 transaction_hash = EXCLUDED.transaction_hash,
2064 current_tick = EXCLUDED.current_tick,
2065 price_sqrt_ratio_x96 = EXCLUDED.price_sqrt_ratio_x96,
2066 liquidity = EXCLUDED.liquidity,
2067 protocol_fees_token0 = EXCLUDED.protocol_fees_token0,
2068 protocol_fees_token1 = EXCLUDED.protocol_fees_token1,
2069 fee_protocol = EXCLUDED.fee_protocol,
2070 fee_protocol0_basis_points = EXCLUDED.fee_protocol0_basis_points,
2071 fee_protocol1_basis_points = EXCLUDED.fee_protocol1_basis_points,
2072 fee_growth_global_0 = EXCLUDED.fee_growth_global_0,
2073 fee_growth_global_1 = EXCLUDED.fee_growth_global_1,
2074 total_amount0_deposited = EXCLUDED.total_amount0_deposited,
2075 total_amount1_deposited = EXCLUDED.total_amount1_deposited,
2076 total_amount0_collected = EXCLUDED.total_amount0_collected,
2077 total_amount1_collected = EXCLUDED.total_amount1_collected,
2078 total_swaps = EXCLUDED.total_swaps,
2079 total_mints = EXCLUDED.total_mints,
2080 total_burns = EXCLUDED.total_burns,
2081 total_fee_collects = EXCLUDED.total_fee_collects,
2082 total_flashes = EXCLUDED.total_flashes,
2083 liquidity_utilization_rate = EXCLUDED.liquidity_utilization_rate
2084 ",
2085 )
2086 .bind(chain_id as i32)
2087 .bind(dex_name.to_string())
2088 .bind(pool_identifier.as_ref())
2089 .bind(snapshot.block_position.number as i64)
2090 .bind(snapshot.block_position.transaction_index as i32)
2091 .bind(snapshot.block_position.log_index as i32)
2092 .bind(snapshot.block_position.transaction_hash.clone())
2093 .bind(snapshot.state.current_tick)
2094 .bind(snapshot.state.price_sqrt_ratio_x96.to_string())
2095 .bind(snapshot.state.liquidity.to_string())
2096 .bind(snapshot.state.protocol_fees_token0.to_string())
2097 .bind(snapshot.state.protocol_fees_token1.to_string())
2098 .bind(snapshot.state.fee_protocol as i16)
2099 .bind(snapshot.state.fee_protocol0_basis_points.map(|v| v as i32))
2100 .bind(snapshot.state.fee_protocol1_basis_points.map(|v| v as i32))
2101 .bind(snapshot.state.fee_growth_global_0.to_string())
2102 .bind(snapshot.state.fee_growth_global_1.to_string())
2103 .bind(snapshot.analytics.total_amount0_deposited.to_string())
2104 .bind(snapshot.analytics.total_amount1_deposited.to_string())
2105 .bind(snapshot.analytics.total_amount0_collected.to_string())
2106 .bind(snapshot.analytics.total_amount1_collected.to_string())
2107 .bind(snapshot.analytics.total_swaps as i32)
2108 .bind(snapshot.analytics.total_mints as i32)
2109 .bind(snapshot.analytics.total_burns as i32)
2110 .bind(snapshot.analytics.total_fee_collects as i32)
2111 .bind(snapshot.analytics.total_flashes as i32)
2112 .bind(snapshot.analytics.liquidity_utilization_rate)
2113 .execute(&self.pool)
2114 .await
2115 .map(|_| ())
2116 .map_err(|e| anyhow::anyhow!("Failed to insert into pool_snapshot table: {e}"))
2117 }
2118
2119 pub async fn add_pool_positions_batch(
2125 &self,
2126 chain_id: u32,
2127 snapshot_block: u64,
2128 snapshot_transaction_index: u32,
2129 snapshot_log_index: u32,
2130 positions: &[(PoolIdentifier, PoolPosition)],
2131 ) -> anyhow::Result<()> {
2132 if positions.is_empty() {
2133 return Ok(());
2134 }
2135
2136 let len = positions.len();
2138 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
2139 let mut owners: Vec<String> = Vec::with_capacity(len);
2140 let mut tick_lowers: Vec<i32> = Vec::with_capacity(len);
2141 let mut tick_uppers: Vec<i32> = Vec::with_capacity(len);
2142 let mut liquidities: Vec<String> = Vec::with_capacity(len);
2143 let mut fee_growth_inside_0_lasts: Vec<String> = Vec::with_capacity(len);
2144 let mut fee_growth_inside_1_lasts: Vec<String> = Vec::with_capacity(len);
2145 let mut tokens_owed_0s: Vec<String> = Vec::with_capacity(len);
2146 let mut tokens_owed_1s: Vec<String> = Vec::with_capacity(len);
2147 let mut total_amount0_depositeds: Vec<Option<String>> = Vec::with_capacity(len);
2148 let mut total_amount1_depositeds: Vec<Option<String>> = Vec::with_capacity(len);
2149 let mut total_amount0_collecteds: Vec<Option<String>> = Vec::with_capacity(len);
2150 let mut total_amount1_collecteds: Vec<Option<String>> = Vec::with_capacity(len);
2151
2152 for (pool_address, position) in positions {
2154 pool_identifiers.push(pool_address.to_string());
2155 owners.push(position.owner.to_string());
2156 tick_lowers.push(position.tick_lower);
2157 tick_uppers.push(position.tick_upper);
2158 liquidities.push(position.liquidity.to_string());
2159 fee_growth_inside_0_lasts.push(position.fee_growth_inside_0_last.to_string());
2160 fee_growth_inside_1_lasts.push(position.fee_growth_inside_1_last.to_string());
2161 tokens_owed_0s.push(position.tokens_owed_0.to_string());
2162 tokens_owed_1s.push(position.tokens_owed_1.to_string());
2163 total_amount0_depositeds.push(Some(position.total_amount0_deposited.to_string()));
2164 total_amount1_depositeds.push(Some(position.total_amount1_deposited.to_string()));
2165 total_amount0_collecteds.push(Some(position.total_amount0_collected.to_string()));
2166 total_amount1_collecteds.push(Some(position.total_amount1_collected.to_string()));
2167 }
2168
2169 sqlx::query(
2171 "
2172 INSERT INTO pool_position (
2173 chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index,
2174 owner, tick_lower, tick_upper,
2175 liquidity, fee_growth_inside_0_last, fee_growth_inside_1_last,
2176 tokens_owed_0, tokens_owed_1,
2177 total_amount0_deposited, total_amount1_deposited,
2178 total_amount0_collected, total_amount1_collected
2179 )
2180 SELECT
2181 $1, pool_identifier, $2, $3, $4,
2182 owner, tick_lower, tick_upper,
2183 liquidity::U128, fee_growth_inside_0_last::U256, fee_growth_inside_1_last::U256,
2184 tokens_owed_0::U128, tokens_owed_1::U128,
2185 total_amount0_deposited::U256, total_amount1_deposited::U256,
2186 total_amount0_collected::U128, total_amount1_collected::U128
2187 FROM UNNEST(
2188 $5::TEXT[], $6::TEXT[], $7::INT[], $8::INT[], $9::TEXT[], $10::TEXT[],
2189 $11::TEXT[], $12::TEXT[], $13::TEXT[], $14::TEXT[], $15::TEXT[],
2190 $16::TEXT[], $17::TEXT[]
2191 ) AS t(pool_identifier, owner, tick_lower, tick_upper,
2192 liquidity, fee_growth_inside_0_last, fee_growth_inside_1_last,
2193 tokens_owed_0, tokens_owed_1,
2194 total_amount0_deposited, total_amount1_deposited,
2195 total_amount0_collected, total_amount1_collected)
2196 ON CONFLICT (chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index, owner, tick_lower, tick_upper)
2197 DO UPDATE SET
2198 liquidity = EXCLUDED.liquidity,
2199 fee_growth_inside_0_last = EXCLUDED.fee_growth_inside_0_last,
2200 fee_growth_inside_1_last = EXCLUDED.fee_growth_inside_1_last,
2201 tokens_owed_0 = EXCLUDED.tokens_owed_0,
2202 tokens_owed_1 = EXCLUDED.tokens_owed_1,
2203 total_amount0_deposited = EXCLUDED.total_amount0_deposited,
2204 total_amount1_deposited = EXCLUDED.total_amount1_deposited,
2205 total_amount0_collected = EXCLUDED.total_amount0_collected,
2206 total_amount1_collected = EXCLUDED.total_amount1_collected
2207 ",
2208 )
2209 .bind(chain_id as i32)
2210 .bind(snapshot_block as i64)
2211 .bind(snapshot_transaction_index as i32)
2212 .bind(snapshot_log_index as i32)
2213 .bind(&pool_identifiers[..])
2214 .bind(&owners[..])
2215 .bind(&tick_lowers[..])
2216 .bind(&tick_uppers[..])
2217 .bind(&liquidities[..])
2218 .bind(&fee_growth_inside_0_lasts[..])
2219 .bind(&fee_growth_inside_1_lasts[..])
2220 .bind(&tokens_owed_0s[..])
2221 .bind(&tokens_owed_1s[..])
2222 .bind(&total_amount0_depositeds as &[Option<String>])
2223 .bind(&total_amount1_depositeds as &[Option<String>])
2224 .bind(&total_amount0_collecteds as &[Option<String>])
2225 .bind(&total_amount1_collecteds as &[Option<String>])
2226 .execute(&self.pool)
2227 .await
2228 .map(|_| ())
2229 .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_position table: {e}"))
2230 }
2231
2232 pub async fn add_pool_ticks_batch(
2238 &self,
2239 chain_id: u32,
2240 snapshot_block: u64,
2241 snapshot_transaction_index: u32,
2242 snapshot_log_index: u32,
2243 ticks: &[(PoolIdentifier, &PoolTick)],
2244 ) -> anyhow::Result<()> {
2245 if ticks.is_empty() {
2246 return Ok(());
2247 }
2248
2249 let len = ticks.len();
2251 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
2252 let mut tick_values: Vec<i32> = Vec::with_capacity(len);
2253 let mut liquidity_grosses: Vec<String> = Vec::with_capacity(len);
2254 let mut liquidity_nets: Vec<String> = Vec::with_capacity(len);
2255 let mut fee_growth_outside_0s: Vec<String> = Vec::with_capacity(len);
2256 let mut fee_growth_outside_1s: Vec<String> = Vec::with_capacity(len);
2257 let mut initializeds: Vec<bool> = Vec::with_capacity(len);
2258 let mut last_updated_blocks: Vec<i64> = Vec::with_capacity(len);
2259
2260 for (pool_address, tick) in ticks {
2262 pool_identifiers.push(pool_address.to_string());
2263 tick_values.push(tick.value);
2264 liquidity_grosses.push(tick.liquidity_gross.to_string());
2265 liquidity_nets.push(tick.liquidity_net.to_string());
2266 fee_growth_outside_0s.push(tick.fee_growth_outside_0.to_string());
2267 fee_growth_outside_1s.push(tick.fee_growth_outside_1.to_string());
2268 initializeds.push(tick.initialized);
2269 last_updated_blocks.push(tick.last_updated_block as i64);
2270 }
2271
2272 sqlx::query(
2274 "
2275 INSERT INTO pool_tick (
2276 chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index,
2277 tick_value, liquidity_gross, liquidity_net,
2278 fee_growth_outside_0, fee_growth_outside_1, initialized, last_updated_block
2279 )
2280 SELECT
2281 $1, pool_identifier, $2, $3, $4,
2282 tick_value, liquidity_gross::U128, liquidity_net::I128,
2283 fee_growth_outside_0::U256, fee_growth_outside_1::U256, initialized, last_updated_block
2284 FROM UNNEST(
2285 $5::TEXT[], $6::INT[], $7::TEXT[], $8::TEXT[], $9::TEXT[],
2286 $10::TEXT[], $11::BOOLEAN[], $12::BIGINT[]
2287 ) AS t(pool_identifier, tick_value, liquidity_gross, liquidity_net,
2288 fee_growth_outside_0, fee_growth_outside_1, initialized, last_updated_block)
2289 ON CONFLICT (chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index, tick_value)
2290 DO UPDATE SET
2291 liquidity_gross = EXCLUDED.liquidity_gross,
2292 liquidity_net = EXCLUDED.liquidity_net,
2293 fee_growth_outside_0 = EXCLUDED.fee_growth_outside_0,
2294 fee_growth_outside_1 = EXCLUDED.fee_growth_outside_1,
2295 initialized = EXCLUDED.initialized,
2296 last_updated_block = EXCLUDED.last_updated_block
2297 ",
2298 )
2299 .bind(chain_id as i32)
2300 .bind(snapshot_block as i64)
2301 .bind(snapshot_transaction_index as i32)
2302 .bind(snapshot_log_index as i32)
2303 .bind(&pool_identifiers[..])
2304 .bind(&tick_values[..])
2305 .bind(&liquidity_grosses[..])
2306 .bind(&liquidity_nets[..])
2307 .bind(&fee_growth_outside_0s[..])
2308 .bind(&fee_growth_outside_1s[..])
2309 .bind(&initializeds[..])
2310 .bind(&last_updated_blocks[..])
2311 .execute(&self.pool)
2312 .await
2313 .map(|_| ())
2314 .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_tick table: {e}"))
2315 }
2316
2317 pub async fn update_pool_initial_price_tick(
2323 &self,
2324 chain_id: u32,
2325 initialize_event: &InitializeEvent,
2326 ) -> anyhow::Result<()> {
2327 sqlx::query(
2328 "
2329 UPDATE pool
2330 SET
2331 initial_tick = $4,
2332 initial_sqrt_price_x96 = $5
2333 WHERE chain_id = $1
2334 AND dex_name = $2
2335 AND pool_identifier = $3
2336 ",
2337 )
2338 .bind(chain_id as i32)
2339 .bind(initialize_event.dex.name.to_string())
2340 .bind(initialize_event.pool_identifier.as_ref())
2341 .bind(initialize_event.tick)
2342 .bind(initialize_event.sqrt_price_x96.to_string())
2343 .execute(&self.pool)
2344 .await
2345 .map(|_| ())
2346 .map_err(|e| anyhow::anyhow!("Failed to update pool initial price and tick: {e}"))
2347 }
2348
2349 pub async fn load_latest_valid_pool_snapshot(
2358 &self,
2359 chain_id: u32,
2360 pool_identifier: &PoolIdentifier,
2361 ) -> anyhow::Result<Option<PoolSnapshot>> {
2362 self.load_latest_pool_snapshot(chain_id, pool_identifier, None, true)
2363 .await
2364 }
2365
2366 pub async fn load_latest_pool_snapshot(
2377 &self,
2378 chain_id: u32,
2379 pool_identifier: &PoolIdentifier,
2380 max_block: Option<u64>,
2381 require_valid: bool,
2382 ) -> anyhow::Result<Option<PoolSnapshot>> {
2383 let allow_invalid = !require_valid;
2384 let result = sqlx::query(
2385 "
2386 SELECT
2387 block, transaction_index, log_index, transaction_hash,
2388 COALESCE(
2389 (SELECT hash FROM pool_event_block WHERE pool_event_block.chain_id = pool_snapshot.chain_id AND pool_event_block.number = pool_snapshot.block),
2390 (SELECT hash FROM block WHERE block.chain_id = pool_snapshot.chain_id AND block.number = pool_snapshot.block)
2391 ) as block_hash,
2392 current_tick, price_sqrt_ratio_x96::TEXT, liquidity::TEXT,
2393 protocol_fees_token0::TEXT, protocol_fees_token1::TEXT, fee_protocol,
2394 fee_protocol0_basis_points, fee_protocol1_basis_points,
2395 fee_growth_global_0::TEXT, fee_growth_global_1::TEXT,
2396 total_amount0_deposited::TEXT, total_amount1_deposited::TEXT,
2397 total_amount0_collected::TEXT, total_amount1_collected::TEXT,
2398 total_swaps, total_mints, total_burns, total_fee_collects, total_flashes,
2399 liquidity_utilization_rate,
2400 (SELECT dex_name FROM pool WHERE chain_id = $1 AND address = $2) as dex_name,
2401 COALESCE(
2402 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_snapshot.chain_id AND block.number = pool_snapshot.block),
2403 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_snapshot.chain_id AND pool_event_block.number = pool_snapshot.block)
2404 ) as block_timestamp
2405 FROM pool_snapshot
2406 WHERE chain_id = $1 AND pool_identifier = $2
2407 AND ($3::BIGINT IS NULL OR block <= $3)
2408 AND ($4 OR validation_state <> 'invalid')
2409 ORDER BY block DESC, transaction_index DESC, log_index DESC
2410 LIMIT 1
2411 ",
2412 )
2413 .bind(chain_id as i32)
2414 .bind(pool_identifier.as_ref())
2415 .bind(max_block.map(|b| b as i64))
2416 .bind(allow_invalid)
2417 .fetch_optional(&self.pool)
2418 .await
2419 .map_err(|e| anyhow::anyhow!("Failed to load latest valid pool snapshot: {e}"))?;
2420
2421 if let Some(row) = result {
2422 let block: i64 = row.get("block");
2424 let transaction_index: i32 = row.get("transaction_index");
2425 let log_index: i32 = row.get("log_index");
2426 let transaction_hash: String = row.get("transaction_hash");
2427 let observed_block_hash = row.try_get::<Option<String>, _>("block_hash")?;
2428 let block =
2429 u64::try_from(block).with_context(|| "Pool snapshot block number is negative")?;
2430 let transaction_index = u32::try_from(transaction_index)
2431 .with_context(|| "Pool snapshot transaction index is negative")?;
2432 let log_index =
2433 u32::try_from(log_index).with_context(|| "Pool snapshot log index is negative")?;
2434 let block_hash = if transaction_index == BLOCK_SCOPED_SNAPSHOT_INDEX
2435 && log_index == BLOCK_SCOPED_SNAPSHOT_INDEX
2436 {
2437 Some(transaction_hash.clone())
2438 } else {
2439 observed_block_hash
2440 };
2441 let block_timestamp = row
2442 .try_get::<Option<String>, _>("block_timestamp")?
2443 .ok_or_else(|| {
2444 anyhow::anyhow!(
2445 "Missing block timestamp for pool snapshot {} at block {}",
2446 pool_identifier,
2447 block
2448 )
2449 })?;
2450 let timestamp = parse_cached_block_timestamp(&block_timestamp)
2451 .map_err(|e| anyhow::anyhow!("Invalid block timestamp '{block_timestamp}': {e}"))?;
2452
2453 let block_position =
2454 BlockPosition::new(block, transaction_hash, transaction_index, log_index)
2455 .with_block_hash(block_hash);
2456
2457 let fee_protocol_value = row.get::<i16, _>("fee_protocol");
2458 let fee_protocol = u8::try_from(fee_protocol_value).with_context(|| {
2459 format!("Invalid pool snapshot fee protocol {fee_protocol_value}")
2460 })?;
2461 let fee_protocol0_basis_points = row
2462 .get::<Option<i32>, _>("fee_protocol0_basis_points")
2463 .map(|value| {
2464 u32::try_from(value).with_context(|| {
2465 format!("Invalid token0 fee protocol basis points {value}")
2466 })
2467 })
2468 .transpose()?;
2469 let fee_protocol1_basis_points = row
2470 .get::<Option<i32>, _>("fee_protocol1_basis_points")
2471 .map(|value| {
2472 u32::try_from(value).with_context(|| {
2473 format!("Invalid token1 fee protocol basis points {value}")
2474 })
2475 })
2476 .transpose()?;
2477
2478 let state = PoolState {
2479 current_tick: row.get("current_tick"),
2480 price_sqrt_ratio_x96: row.get::<String, _>("price_sqrt_ratio_x96").parse()?,
2481 liquidity: row.get::<String, _>("liquidity").parse()?,
2482 protocol_fees_token0: row.get::<String, _>("protocol_fees_token0").parse()?,
2483 protocol_fees_token1: row.get::<String, _>("protocol_fees_token1").parse()?,
2484 fee_protocol,
2485 fee_protocol0_basis_points,
2486 fee_protocol1_basis_points,
2487 fee_growth_global_0: row.get::<String, _>("fee_growth_global_0").parse()?,
2488 fee_growth_global_1: row.get::<String, _>("fee_growth_global_1").parse()?,
2489 };
2490
2491 let analytics = PoolAnalytics {
2492 total_amount0_deposited: row.get::<String, _>("total_amount0_deposited").parse()?,
2493 total_amount1_deposited: row.get::<String, _>("total_amount1_deposited").parse()?,
2494 total_amount0_collected: row.get::<String, _>("total_amount0_collected").parse()?,
2495 total_amount1_collected: row.get::<String, _>("total_amount1_collected").parse()?,
2496 total_swaps: u64::try_from(row.get::<i32, _>("total_swaps"))
2497 .with_context(|| "Pool snapshot total swaps is negative")?,
2498 total_mints: u64::try_from(row.get::<i32, _>("total_mints"))
2499 .with_context(|| "Pool snapshot total mints is negative")?,
2500 total_burns: u64::try_from(row.get::<i32, _>("total_burns"))
2501 .with_context(|| "Pool snapshot total burns is negative")?,
2502 total_fee_collects: u64::try_from(row.get::<i32, _>("total_fee_collects"))
2503 .with_context(|| "Pool snapshot total fee collects is negative")?,
2504 total_flashes: u64::try_from(row.get::<i32, _>("total_flashes"))
2505 .with_context(|| "Pool snapshot total flashes is negative")?,
2506 liquidity_utilization_rate: row.get::<f64, _>("liquidity_utilization_rate"),
2507 };
2508
2509 let positions = self
2511 .load_pool_positions_for_snapshot(
2512 chain_id,
2513 pool_identifier,
2514 block,
2515 transaction_index,
2516 log_index,
2517 )
2518 .await?;
2519
2520 let ticks = self
2521 .load_pool_ticks_for_snapshot(
2522 chain_id,
2523 pool_identifier,
2524 block,
2525 transaction_index,
2526 log_index,
2527 )
2528 .await?;
2529
2530 let dex_name = row
2531 .try_get::<Option<String>, _>("dex_name")?
2532 .ok_or_else(|| {
2533 anyhow::anyhow!("Missing dex_name for pool snapshot {}", pool_identifier)
2534 })?;
2535 let chain = Chain::from_chain_id(chain_id)
2536 .ok_or_else(|| anyhow::anyhow!("Unknown chain_id: {chain_id}"))?;
2537
2538 let dex_type = DexType::from_dex_name(&dex_name)
2539 .ok_or_else(|| anyhow::anyhow!("Unknown dex_name: {dex_name}"))?;
2540
2541 let dex_extended = crate::exchanges::get_dex_extended(chain.name, &dex_type)
2542 .ok_or_else(|| {
2543 anyhow::anyhow!("No DEX extended found for {} on {}", dex_name, chain.name)
2544 })?;
2545
2546 let instrument_id =
2547 Pool::create_instrument_id(chain.name, &dex_extended.dex, pool_identifier.as_ref());
2548
2549 Ok(Some(PoolSnapshot::new(
2550 instrument_id,
2551 state,
2552 positions,
2553 ticks,
2554 analytics,
2555 block_position,
2556 timestamp, timestamp, )))
2559 } else {
2560 Ok(None)
2561 }
2562 }
2563
2564 pub async fn set_pool_snapshot_validation_state(
2573 &self,
2574 chain_id: u32,
2575 pool_identifier: &PoolIdentifier,
2576 block: u64,
2577 transaction_index: u32,
2578 log_index: u32,
2579 state: &str,
2580 ) -> anyhow::Result<()> {
2581 sqlx::query(
2582 "
2583 UPDATE pool_snapshot
2584 SET validation_state = $6
2585 WHERE chain_id = $1
2586 AND pool_identifier = $2
2587 AND block = $3
2588 AND transaction_index = $4
2589 AND log_index = $5
2590 ",
2591 )
2592 .bind(chain_id as i32)
2593 .bind(pool_identifier.as_ref())
2594 .bind(block as i64)
2595 .bind(transaction_index as i32)
2596 .bind(log_index as i32)
2597 .bind(state)
2598 .execute(&self.pool)
2599 .await
2600 .map(|_| ())
2601 .map_err(|e| anyhow::anyhow!("Failed to set pool snapshot validation state: {e}"))
2602 }
2603
2604 pub async fn get_pool_snapshot_validation_state(
2613 &self,
2614 chain_id: u32,
2615 pool_identifier: &PoolIdentifier,
2616 block: u64,
2617 transaction_index: u32,
2618 log_index: u32,
2619 ) -> anyhow::Result<Option<String>> {
2620 let row = sqlx::query(
2621 "
2622 SELECT validation_state
2623 FROM pool_snapshot
2624 WHERE chain_id = $1
2625 AND pool_identifier = $2
2626 AND block = $3
2627 AND transaction_index = $4
2628 AND log_index = $5
2629 ",
2630 )
2631 .bind(chain_id as i32)
2632 .bind(pool_identifier.as_ref())
2633 .bind(block as i64)
2634 .bind(transaction_index as i32)
2635 .bind(log_index as i32)
2636 .fetch_optional(&self.pool)
2637 .await
2638 .map_err(|e| anyhow::anyhow!("Failed to get pool snapshot validation state: {e}"))?;
2639
2640 Ok(row.map(|row| row.get::<String, _>("validation_state")))
2641 }
2642
2643 pub async fn load_pool_positions_for_snapshot(
2649 &self,
2650 chain_id: u32,
2651 pool_identifier: &PoolIdentifier,
2652 snapshot_block: u64,
2653 snapshot_transaction_index: u32,
2654 snapshot_log_index: u32,
2655 ) -> anyhow::Result<Vec<PoolPosition>> {
2656 let rows = sqlx::query(
2657 "
2658 SELECT
2659 owner, tick_lower, tick_upper,
2660 liquidity::TEXT, fee_growth_inside_0_last::TEXT, fee_growth_inside_1_last::TEXT,
2661 tokens_owed_0::TEXT, tokens_owed_1::TEXT,
2662 total_amount0_deposited::TEXT, total_amount1_deposited::TEXT,
2663 total_amount0_collected::TEXT, total_amount1_collected::TEXT
2664 FROM pool_position
2665 WHERE chain_id = $1
2666 AND pool_identifier = $2
2667 AND snapshot_block = $3
2668 AND snapshot_transaction_index = $4
2669 AND snapshot_log_index = $5
2670 ",
2671 )
2672 .bind(chain_id as i32)
2673 .bind(pool_identifier.as_ref())
2674 .bind(snapshot_block as i64)
2675 .bind(snapshot_transaction_index as i32)
2676 .bind(snapshot_log_index as i32)
2677 .fetch_all(&self.pool)
2678 .await
2679 .map_err(|e| anyhow::anyhow!("Failed to load pool positions: {e}"))?;
2680
2681 rows.iter()
2682 .map(|row| {
2683 let owner: String = row.get("owner");
2684 let position = PoolPosition {
2685 owner: validate_address(&owner)?,
2686 tick_lower: row.get("tick_lower"),
2687 tick_upper: row.get("tick_upper"),
2688 liquidity: row.get::<String, _>("liquidity").parse()?,
2689 fee_growth_inside_0_last: row
2690 .get::<String, _>("fee_growth_inside_0_last")
2691 .parse()?,
2692 fee_growth_inside_1_last: row
2693 .get::<String, _>("fee_growth_inside_1_last")
2694 .parse()?,
2695 tokens_owed_0: row.get::<String, _>("tokens_owed_0").parse()?,
2696 tokens_owed_1: row.get::<String, _>("tokens_owed_1").parse()?,
2697 total_amount0_deposited: row
2698 .get::<String, _>("total_amount0_deposited")
2699 .parse()?,
2700 total_amount1_deposited: row
2701 .get::<String, _>("total_amount1_deposited")
2702 .parse()?,
2703 total_amount0_collected: row
2704 .get::<String, _>("total_amount0_collected")
2705 .parse()?,
2706 total_amount1_collected: row
2707 .get::<String, _>("total_amount1_collected")
2708 .parse()?,
2709 };
2710 Ok(position)
2711 })
2712 .collect()
2713 }
2714
2715 pub async fn load_pool_ticks_for_snapshot(
2721 &self,
2722 chain_id: u32,
2723 pool_identifier: &PoolIdentifier,
2724 snapshot_block: u64,
2725 snapshot_transaction_index: u32,
2726 snapshot_log_index: u32,
2727 ) -> anyhow::Result<Vec<PoolTick>> {
2728 let rows = sqlx::query(
2729 "
2730 SELECT
2731 tick_value, liquidity_gross::TEXT, liquidity_net::TEXT,
2732 fee_growth_outside_0::TEXT, fee_growth_outside_1::TEXT, initialized,
2733 last_updated_block
2734 FROM pool_tick
2735 WHERE chain_id = $1
2736 AND pool_identifier = $2
2737 AND snapshot_block = $3
2738 AND snapshot_transaction_index = $4
2739 AND snapshot_log_index = $5
2740 ",
2741 )
2742 .bind(chain_id as i32)
2743 .bind(pool_identifier.as_ref())
2744 .bind(snapshot_block as i64)
2745 .bind(snapshot_transaction_index as i32)
2746 .bind(snapshot_log_index as i32)
2747 .fetch_all(&self.pool)
2748 .await
2749 .map_err(|e| anyhow::anyhow!("Failed to load pool ticks: {e}"))?;
2750
2751 rows.iter()
2752 .map(|row| {
2753 let tick = PoolTick::new(
2754 row.get("tick_value"),
2755 row.get::<String, _>("liquidity_gross").parse()?,
2756 row.get::<String, _>("liquidity_net").parse()?,
2757 row.get::<String, _>("fee_growth_outside_0").parse()?,
2758 row.get::<String, _>("fee_growth_outside_1").parse()?,
2759 row.get("initialized"),
2760 u64::try_from(row.get::<i64, _>("last_updated_block"))
2761 .with_context(|| "Pool tick last updated block is negative")?,
2762 );
2763 Ok(tick)
2764 })
2765 .collect()
2766 }
2767
2768 pub fn stream_pool_events<'a>(
2782 &'a self,
2783 chain: SharedChain,
2784 dex: SharedDex,
2785 instrument_id: InstrumentId,
2786 pool_identifier: PoolIdentifier,
2787 from_position: Option<BlockPosition>,
2788 to_block: Option<u64>,
2789 ) -> Pin<Box<dyn Stream<Item = Result<DexPoolData, anyhow::Error>> + Send + 'a>> {
2790 const QUERY_ALL: &str = "
2791 SELECT events.*,
2792 COALESCE(
2793 (SELECT hash FROM pool_event_block WHERE pool_event_block.chain_id = events.chain_id AND pool_event_block.number = events.block),
2794 (SELECT hash FROM block WHERE block.chain_id = events.chain_id AND block.number = events.block)
2795 ) as block_hash
2796 FROM (
2797 (SELECT
2798 'swap' as event_type,
2799 chain_id,
2800 pool_identifier,
2801 block,
2802 transaction_hash,
2803 transaction_index,
2804 log_index,
2805 COALESCE(
2806 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_swap_event.chain_id AND block.number = pool_swap_event.block),
2807 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_swap_event.chain_id AND pool_event_block.number = pool_swap_event.block)
2808 ) as block_timestamp,
2809 sender,
2810 recipient,
2811 NULL::TEXT as owner,
2812 sqrt_price_x96::TEXT,
2813 liquidity::TEXT as swap_liquidity,
2814 tick as swap_tick,
2815 amount0::TEXT as swap_amount0,
2816 amount1::TEXT as swap_amount1,
2817 NULL::TEXT as position_liquidity,
2818 NULL::TEXT as amount0,
2819 NULL::TEXT as amount1,
2820 NULL::INT as tick_lower,
2821 NULL::INT as tick_upper,
2822 NULL::TEXT as liquidity_event_type,
2823 NULL::TEXT as flash_amount0,
2824 NULL::TEXT as flash_amount1,
2825 NULL::TEXT as flash_paid0,
2826 NULL::TEXT as flash_paid1,
2827 NULL::INTEGER as fee_protocol0_new,
2828 NULL::INTEGER as fee_protocol1_new
2829 FROM pool_swap_event
2830 WHERE chain_id = $1 AND pool_identifier = $2
2831 AND ($3::BIGINT IS NULL OR block <= $3))
2832 UNION ALL
2833 (SELECT
2834 'liquidity' as event_type,
2835 chain_id,
2836 pool_identifier,
2837 block,
2838 transaction_hash,
2839 transaction_index,
2840 log_index,
2841 COALESCE(
2842 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_liquidity_event.chain_id AND block.number = pool_liquidity_event.block),
2843 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_liquidity_event.chain_id AND pool_event_block.number = pool_liquidity_event.block)
2844 ) as block_timestamp,
2845 sender,
2846 NULL::TEXT as recipient,
2847 owner,
2848 NULL::text as sqrt_price_x96,
2849 NULL::TEXT as swap_liquidity,
2850 NULL::INT as swap_tick,
2851 amount0::TEXT as swap_amount0,
2852 amount1::TEXT as swap_amount1,
2853 position_liquidity::TEXT,
2854 amount0::TEXT,
2855 amount1::TEXT,
2856 tick_lower::INT,
2857 tick_upper::INT,
2858 event_type as liquidity_event_type,
2859 NULL::TEXT as flash_amount0,
2860 NULL::TEXT as flash_amount1,
2861 NULL::TEXT as flash_paid0,
2862 NULL::TEXT as flash_paid1,
2863 NULL::INTEGER as fee_protocol0_new,
2864 NULL::INTEGER as fee_protocol1_new
2865 FROM pool_liquidity_event
2866 WHERE chain_id = $1 AND pool_identifier = $2
2867 AND ($3::BIGINT IS NULL OR block <= $3))
2868 UNION ALL
2869 (SELECT
2870 'collect' as event_type,
2871 chain_id,
2872 pool_identifier,
2873 block,
2874 transaction_hash,
2875 transaction_index,
2876 log_index,
2877 COALESCE(
2878 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_collect_event.chain_id AND block.number = pool_collect_event.block),
2879 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_collect_event.chain_id AND pool_event_block.number = pool_collect_event.block)
2880 ) as block_timestamp,
2881 NULL::TEXT as sender,
2882 NULL::TEXT as recipient,
2883 owner,
2884 NULL::TEXT as sqrt_price_x96,
2885 NULL::TEXT as swap_liquidity,
2886 NULL::INT AS swap_tick,
2887 amount0::TEXT as swap_amount0,
2888 amount1::TEXT as swap_amount1,
2889 NULL::TEXT as position_liquidity,
2890 amount0::TEXT,
2891 amount1::TEXT,
2892 tick_lower::INT,
2893 tick_upper::INT,
2894 NULL::TEXT as liquidity_event_type,
2895 NULL::TEXT as flash_amount0,
2896 NULL::TEXT as flash_amount1,
2897 NULL::TEXT as flash_paid0,
2898 NULL::TEXT as flash_paid1,
2899 NULL::INTEGER as fee_protocol0_new,
2900 NULL::INTEGER as fee_protocol1_new
2901 FROM pool_collect_event
2902 WHERE chain_id = $1 AND pool_identifier = $2
2903 AND ($3::BIGINT IS NULL OR block <= $3))
2904 UNION ALL
2905 (SELECT
2906 'fee_protocol_update' as event_type,
2907 chain_id,
2908 pool_identifier,
2909 block,
2910 transaction_hash,
2911 transaction_index,
2912 log_index,
2913 COALESCE(
2914 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_fee_protocol_update_event.chain_id AND block.number = pool_fee_protocol_update_event.block),
2915 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_fee_protocol_update_event.chain_id AND pool_event_block.number = pool_fee_protocol_update_event.block)
2916 ) as block_timestamp,
2917 NULL::TEXT as sender,
2918 NULL::TEXT as recipient,
2919 NULL::TEXT as owner,
2920 NULL::TEXT as sqrt_price_x96,
2921 NULL::TEXT as swap_liquidity,
2922 NULL::INT AS swap_tick,
2923 NULL::TEXT as swap_amount0,
2924 NULL::TEXT as swap_amount1,
2925 NULL::TEXT as position_liquidity,
2926 NULL::TEXT as amount0,
2927 NULL::TEXT as amount1,
2928 NULL::INT as tick_lower,
2929 NULL::INT as tick_upper,
2930 NULL::TEXT as liquidity_event_type,
2931 NULL::TEXT as flash_amount0,
2932 NULL::TEXT as flash_amount1,
2933 NULL::TEXT as flash_paid0,
2934 NULL::TEXT as flash_paid1,
2935 fee_protocol0_new::INTEGER,
2936 fee_protocol1_new::INTEGER
2937 FROM pool_fee_protocol_update_event
2938 WHERE chain_id = $1 AND pool_identifier = $2
2939 AND ($3::BIGINT IS NULL OR block <= $3))
2940 UNION ALL
2941 (SELECT
2942 'fee_protocol_collect' as event_type,
2943 chain_id,
2944 pool_identifier,
2945 block,
2946 transaction_hash,
2947 transaction_index,
2948 log_index,
2949 COALESCE(
2950 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_fee_protocol_collect_event.chain_id AND block.number = pool_fee_protocol_collect_event.block),
2951 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_fee_protocol_collect_event.chain_id AND pool_event_block.number = pool_fee_protocol_collect_event.block)
2952 ) as block_timestamp,
2953 sender,
2954 recipient,
2955 NULL::TEXT as owner,
2956 NULL::TEXT as sqrt_price_x96,
2957 NULL::TEXT as swap_liquidity,
2958 NULL::INT AS swap_tick,
2959 NULL::TEXT as swap_amount0,
2960 NULL::TEXT as swap_amount1,
2961 NULL::TEXT as position_liquidity,
2962 amount0::TEXT,
2963 amount1::TEXT,
2964 NULL::INT as tick_lower,
2965 NULL::INT as tick_upper,
2966 NULL::TEXT as liquidity_event_type,
2967 NULL::TEXT as flash_amount0,
2968 NULL::TEXT as flash_amount1,
2969 NULL::TEXT as flash_paid0,
2970 NULL::TEXT as flash_paid1,
2971 NULL::INTEGER as fee_protocol0_new,
2972 NULL::INTEGER as fee_protocol1_new
2973 FROM pool_fee_protocol_collect_event
2974 WHERE chain_id = $1 AND pool_identifier = $2
2975 AND ($3::BIGINT IS NULL OR block <= $3))
2976 UNION ALL
2977 (SELECT
2978 'flash' as event_type,
2979 chain_id,
2980 pool_identifier,
2981 block,
2982 transaction_hash,
2983 transaction_index,
2984 log_index,
2985 COALESCE(
2986 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_flash_event.chain_id AND block.number = pool_flash_event.block),
2987 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_flash_event.chain_id AND pool_event_block.number = pool_flash_event.block)
2988 ) as block_timestamp,
2989 sender,
2990 recipient,
2991 NULL::TEXT as owner,
2992 NULL::TEXT as sqrt_price_x96,
2993 NULL::TEXT as swap_liquidity,
2994 NULL::INT AS swap_tick,
2995 NULL::TEXT as swap_amount0,
2996 NULL::TEXT as swap_amount1,
2997 NULL::TEXT as position_liquidity,
2998 NULL::TEXT as amount0,
2999 NULL::TEXT as amount1,
3000 NULL::INT as tick_lower,
3001 NULL::INT as tick_upper,
3002 NULL::TEXT as liquidity_event_type,
3003 amount0::TEXT as flash_amount0,
3004 amount1::TEXT as flash_amount1,
3005 paid0::TEXT as flash_paid0,
3006 paid1::TEXT as flash_paid1,
3007 NULL::INTEGER as fee_protocol0_new,
3008 NULL::INTEGER as fee_protocol1_new
3009 FROM pool_flash_event
3010 WHERE chain_id = $1 AND pool_identifier = $2
3011 AND ($3::BIGINT IS NULL OR block <= $3))
3012 ) AS events
3013 ORDER BY events.block, events.transaction_index, events.log_index";
3014
3015 const QUERY_FROM_POSITION: &str = "
3016 SELECT events.*,
3017 COALESCE(
3018 (SELECT hash FROM pool_event_block WHERE pool_event_block.chain_id = events.chain_id AND pool_event_block.number = events.block),
3019 (SELECT hash FROM block WHERE block.chain_id = events.chain_id AND block.number = events.block)
3020 ) as block_hash
3021 FROM (
3022 (SELECT
3023 'swap' as event_type,
3024 chain_id,
3025 pool_identifier,
3026 block,
3027 transaction_hash,
3028 transaction_index,
3029 log_index,
3030 COALESCE(
3031 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_swap_event.chain_id AND block.number = pool_swap_event.block),
3032 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_swap_event.chain_id AND pool_event_block.number = pool_swap_event.block)
3033 ) as block_timestamp,
3034 sender,
3035 recipient,
3036 NULL::TEXT as owner,
3037 sqrt_price_x96::TEXT,
3038 liquidity::TEXT as swap_liquidity,
3039 tick as swap_tick,
3040 amount0::TEXT as swap_amount0,
3041 amount1::TEXT as swap_amount1,
3042 NULL::TEXT as position_liquidity,
3043 NULL::TEXT as amount0,
3044 NULL::TEXT as amount1,
3045 NULL::INT as tick_lower,
3046 NULL::INT as tick_upper,
3047 NULL::TEXT as liquidity_event_type,
3048 NULL::TEXT as flash_amount0,
3049 NULL::TEXT as flash_amount1,
3050 NULL::TEXT as flash_paid0,
3051 NULL::TEXT as flash_paid1,
3052 NULL::INTEGER as fee_protocol0_new,
3053 NULL::INTEGER as fee_protocol1_new
3054 FROM pool_swap_event
3055 WHERE chain_id = $1 AND pool_identifier = $2
3056 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3057 AND ($6::BIGINT IS NULL OR block <= $6))
3058 UNION ALL
3059 (SELECT
3060 'liquidity' as event_type,
3061 chain_id,
3062 pool_identifier,
3063 block,
3064 transaction_hash,
3065 transaction_index,
3066 log_index,
3067 COALESCE(
3068 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_liquidity_event.chain_id AND block.number = pool_liquidity_event.block),
3069 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_liquidity_event.chain_id AND pool_event_block.number = pool_liquidity_event.block)
3070 ) as block_timestamp,
3071 sender,
3072 NULL::TEXT as recipient,
3073 owner,
3074 NULL::text as sqrt_price_x96,
3075 NULL::TEXT as swap_liquidity,
3076 NULL::INT as swap_tick,
3077 amount0::TEXT as swap_amount0,
3078 amount1::TEXT as swap_amount1,
3079 position_liquidity::TEXT,
3080 amount0::TEXT,
3081 amount1::TEXT,
3082 tick_lower::INT,
3083 tick_upper::INT,
3084 event_type as liquidity_event_type,
3085 NULL::TEXT as flash_amount0,
3086 NULL::TEXT as flash_amount1,
3087 NULL::TEXT as flash_paid0,
3088 NULL::TEXT as flash_paid1,
3089 NULL::INTEGER as fee_protocol0_new,
3090 NULL::INTEGER as fee_protocol1_new
3091 FROM pool_liquidity_event
3092 WHERE chain_id = $1 AND pool_identifier = $2
3093 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3094 AND ($6::BIGINT IS NULL OR block <= $6))
3095 UNION ALL
3096 (SELECT
3097 'collect' as event_type,
3098 chain_id,
3099 pool_identifier,
3100 block,
3101 transaction_hash,
3102 transaction_index,
3103 log_index,
3104 COALESCE(
3105 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_collect_event.chain_id AND block.number = pool_collect_event.block),
3106 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_collect_event.chain_id AND pool_event_block.number = pool_collect_event.block)
3107 ) as block_timestamp,
3108 NULL::TEXT as sender,
3109 NULL::TEXT as recipient,
3110 owner,
3111 NULL::TEXT as sqrt_price_x96,
3112 NULL::TEXT as swap_liquidity,
3113 NULL::INT AS swap_tick,
3114 amount0::TEXT as swap_amount0,
3115 amount1::TEXT as swap_amount1,
3116 NULL::TEXT as position_liquidity,
3117 amount0::TEXT,
3118 amount1::TEXT,
3119 tick_lower::INT,
3120 tick_upper::INT,
3121 NULL::TEXT as liquidity_event_type,
3122 NULL::TEXT as flash_amount0,
3123 NULL::TEXT as flash_amount1,
3124 NULL::TEXT as flash_paid0,
3125 NULL::TEXT as flash_paid1,
3126 NULL::INTEGER as fee_protocol0_new,
3127 NULL::INTEGER as fee_protocol1_new
3128 FROM pool_collect_event
3129 WHERE chain_id = $1 AND pool_identifier = $2
3130 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3131 AND ($6::BIGINT IS NULL OR block <= $6))
3132 UNION ALL
3133 (SELECT
3134 'fee_protocol_update' as event_type,
3135 chain_id,
3136 pool_identifier,
3137 block,
3138 transaction_hash,
3139 transaction_index,
3140 log_index,
3141 COALESCE(
3142 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_fee_protocol_update_event.chain_id AND block.number = pool_fee_protocol_update_event.block),
3143 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_fee_protocol_update_event.chain_id AND pool_event_block.number = pool_fee_protocol_update_event.block)
3144 ) as block_timestamp,
3145 NULL::TEXT as sender,
3146 NULL::TEXT as recipient,
3147 NULL::TEXT as owner,
3148 NULL::TEXT as sqrt_price_x96,
3149 NULL::TEXT as swap_liquidity,
3150 NULL::INT AS swap_tick,
3151 NULL::TEXT as swap_amount0,
3152 NULL::TEXT as swap_amount1,
3153 NULL::TEXT as position_liquidity,
3154 NULL::TEXT as amount0,
3155 NULL::TEXT as amount1,
3156 NULL::INT as tick_lower,
3157 NULL::INT as tick_upper,
3158 NULL::TEXT as liquidity_event_type,
3159 NULL::TEXT as flash_amount0,
3160 NULL::TEXT as flash_amount1,
3161 NULL::TEXT as flash_paid0,
3162 NULL::TEXT as flash_paid1,
3163 fee_protocol0_new::INTEGER,
3164 fee_protocol1_new::INTEGER
3165 FROM pool_fee_protocol_update_event
3166 WHERE chain_id = $1 AND pool_identifier = $2
3167 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3168 AND ($6::BIGINT IS NULL OR block <= $6))
3169 UNION ALL
3170 (SELECT
3171 'fee_protocol_collect' as event_type,
3172 chain_id,
3173 pool_identifier,
3174 block,
3175 transaction_hash,
3176 transaction_index,
3177 log_index,
3178 COALESCE(
3179 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_fee_protocol_collect_event.chain_id AND block.number = pool_fee_protocol_collect_event.block),
3180 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_fee_protocol_collect_event.chain_id AND pool_event_block.number = pool_fee_protocol_collect_event.block)
3181 ) as block_timestamp,
3182 sender,
3183 recipient,
3184 NULL::TEXT as owner,
3185 NULL::TEXT as sqrt_price_x96,
3186 NULL::TEXT as swap_liquidity,
3187 NULL::INT AS swap_tick,
3188 NULL::TEXT as swap_amount0,
3189 NULL::TEXT as swap_amount1,
3190 NULL::TEXT as position_liquidity,
3191 amount0::TEXT,
3192 amount1::TEXT,
3193 NULL::INT as tick_lower,
3194 NULL::INT as tick_upper,
3195 NULL::TEXT as liquidity_event_type,
3196 NULL::TEXT as flash_amount0,
3197 NULL::TEXT as flash_amount1,
3198 NULL::TEXT as flash_paid0,
3199 NULL::TEXT as flash_paid1,
3200 NULL::INTEGER as fee_protocol0_new,
3201 NULL::INTEGER as fee_protocol1_new
3202 FROM pool_fee_protocol_collect_event
3203 WHERE chain_id = $1 AND pool_identifier = $2
3204 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3205 AND ($6::BIGINT IS NULL OR block <= $6))
3206 UNION ALL
3207 (SELECT
3208 'flash' as event_type,
3209 chain_id,
3210 pool_identifier,
3211 block,
3212 transaction_hash,
3213 transaction_index,
3214 log_index,
3215 COALESCE(
3216 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_flash_event.chain_id AND block.number = pool_flash_event.block),
3217 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_flash_event.chain_id AND pool_event_block.number = pool_flash_event.block)
3218 ) as block_timestamp,
3219 sender,
3220 recipient,
3221 NULL::TEXT as owner,
3222 NULL::TEXT as sqrt_price_x96,
3223 NULL::TEXT as swap_liquidity,
3224 NULL::INT AS swap_tick,
3225 NULL::TEXT as swap_amount0,
3226 NULL::TEXT as swap_amount1,
3227 NULL::TEXT as position_liquidity,
3228 NULL::TEXT as amount0,
3229 NULL::TEXT as amount1,
3230 NULL::INT as tick_lower,
3231 NULL::INT as tick_upper,
3232 NULL::TEXT as liquidity_event_type,
3233 amount0::TEXT as flash_amount0,
3234 amount1::TEXT as flash_amount1,
3235 paid0::TEXT as flash_paid0,
3236 paid1::TEXT as flash_paid1,
3237 NULL::INTEGER as fee_protocol0_new,
3238 NULL::INTEGER as fee_protocol1_new
3239 FROM pool_flash_event
3240 WHERE chain_id = $1 AND pool_identifier = $2
3241 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3242 AND ($6::BIGINT IS NULL OR block <= $6))
3243 ) AS events
3244 ORDER BY events.block, events.transaction_index, events.log_index";
3245
3246 let query = if let Some(pos) = from_position {
3248 sqlx::query(QUERY_FROM_POSITION)
3249 .bind(chain.chain_id as i32)
3250 .bind(pool_identifier.to_string())
3251 .bind(pos.number as i64)
3252 .bind(pos.transaction_index as i32)
3253 .bind(pos.log_index as i32)
3254 .bind(to_block.map(|block| block as i64))
3255 .fetch(&self.pool)
3256 } else {
3257 sqlx::query(QUERY_ALL)
3258 .bind(chain.chain_id as i32)
3259 .bind(pool_identifier.to_string())
3260 .bind(to_block.map(|block| block as i64))
3261 .fetch(&self.pool)
3262 };
3263
3264 let stream = query.map(move |row_result| match row_result {
3266 Ok(row) => transform_row_to_dex_pool_data(
3267 &row,
3268 Arc::clone(&chain),
3269 Arc::clone(&dex),
3270 instrument_id,
3271 )
3272 .map_err(|e| anyhow::anyhow!("Steam pool event transform error: {e}")),
3273 Err(e) => Err(anyhow::anyhow!("Stream pool events database error: {e}")),
3274 });
3275
3276 Box::pin(stream)
3277 }
3278
3279 #[expect(
3291 clippy::too_many_arguments,
3292 reason = "the parameters mirror the persisted execution transaction fields"
3293 )]
3294 pub async fn add_execution_transaction(
3295 &self,
3296 chain_id: u32,
3297 wallet_address: &str,
3298 nonce: u64,
3299 transaction_hash: &str,
3300 purpose: &str,
3301 status: &str,
3302 client_order_id: Option<&str>,
3303 ) -> anyhow::Result<()> {
3304 let result = sqlx::query(
3305 "
3306 INSERT INTO execution_transaction (
3307 chain_id,
3308 wallet_address,
3309 nonce,
3310 transaction_hash,
3311 purpose,
3312 status,
3313 client_order_id
3314 )
3315 VALUES ($1, $2, $3, $4, $5, $6, $7)
3316 ON CONFLICT (chain_id, transaction_hash)
3317 DO UPDATE SET transaction_hash = EXCLUDED.transaction_hash
3318 WHERE execution_transaction.wallet_address = EXCLUDED.wallet_address
3319 AND execution_transaction.nonce = EXCLUDED.nonce
3320 AND execution_transaction.purpose = EXCLUDED.purpose
3321 AND execution_transaction.status = EXCLUDED.status
3322 AND execution_transaction.client_order_id IS NOT DISTINCT FROM EXCLUDED.client_order_id
3323 ",
3324 )
3325 .bind(chain_id as i32)
3326 .bind(wallet_address)
3327 .bind(nonce as i64)
3328 .bind(transaction_hash)
3329 .bind(purpose)
3330 .bind(status)
3331 .bind(client_order_id)
3332 .execute(&self.pool)
3333 .await
3334 .map_err(|e| anyhow::anyhow!("Failed to insert into execution_transaction table: {e}"))?;
3335
3336 anyhow::ensure!(
3337 result.rows_affected() == 1,
3338 "Execution transaction {transaction_hash} conflicts with its persisted record"
3339 );
3340 Ok(())
3341 }
3342
3343 pub async fn ensure_execution_transaction_schema(&self) -> anyhow::Result<()> {
3354 let mut transaction = self
3355 .pool
3356 .begin()
3357 .await
3358 .map_err(|e| anyhow::anyhow!("Failed to start execution schema migration: {e}"))?;
3359
3360 sqlx::query("LOCK TABLE execution_transaction IN ACCESS EXCLUSIVE MODE")
3361 .execute(&mut *transaction)
3362 .await
3363 .map_err(|e| anyhow::anyhow!("Failed to lock legacy execution transactions: {e}"))?;
3364
3365 for statement in [
3366 "
3367 ALTER TABLE execution_transaction
3368 ADD COLUMN IF NOT EXISTS client_order_id TEXT
3369 ",
3370 "
3371 ALTER TABLE execution_transaction
3372 ADD COLUMN IF NOT EXISTS wallet_address TEXT
3373 ",
3374 "
3375 ALTER TABLE execution_transaction
3376 ALTER COLUMN wallet_address DROP NOT NULL
3377 ",
3378 "
3379 CREATE TABLE IF NOT EXISTS execution_schema_version (
3380 component TEXT PRIMARY KEY,
3381 version SMALLINT NOT NULL CHECK (version > 0)
3382 )
3383 ",
3384 "
3385 CREATE TABLE IF NOT EXISTS execution_intent (
3386 id BIGSERIAL PRIMARY KEY,
3387 schema_version SMALLINT NOT NULL,
3388 chain_id INTEGER NOT NULL REFERENCES chain(chain_id) ON DELETE RESTRICT,
3389 wallet_address TEXT NOT NULL,
3390 nonce BIGINT,
3391 purpose TEXT NOT NULL,
3392 status TEXT NOT NULL,
3393 client_order_id TEXT,
3394 trader_id TEXT,
3395 strategy_id TEXT,
3396 account_id TEXT,
3397 instrument_id TEXT,
3398 pool_address TEXT,
3399 transaction_to TEXT NOT NULL,
3400 transaction_input TEXT NOT NULL,
3401 transaction_value TEXT NOT NULL,
3402 amount_in TEXT,
3403 created_block BIGINT NOT NULL,
3404 acknowledgement_emitted BOOLEAN NOT NULL DEFAULT FALSE,
3405 fill_emitted BOOLEAN NOT NULL DEFAULT FALSE,
3406 terminal_emitted BOOLEAN NOT NULL DEFAULT FALSE,
3407 active BOOLEAN NOT NULL DEFAULT TRUE,
3408 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3409 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3410 CHECK (schema_version = 2),
3411 CHECK (nonce IS NULL OR nonce >= 0),
3412 CHECK (created_block >= 0),
3413 CHECK (status IN (
3414 'prepared', 'signed', 'broadcast', 'included', 'finalized',
3415 'reverted', 'replaced', 'dropped', 'reorged', 'recoverable'
3416 )),
3417 CONSTRAINT execution_intent_active_check CHECK (
3418 NOT active
3419 OR status NOT IN ('finalized', 'reverted', 'recoverable')
3420 OR (
3421 status IN ('finalized', 'reverted')
3422 AND NOT (fill_emitted OR terminal_emitted)
3423 )
3424 ),
3425 CHECK (NOT (fill_emitted AND terminal_emitted)),
3426 CHECK (
3427 purpose <> 'swap'
3428 OR (
3429 client_order_id IS NOT NULL
3430 AND trader_id IS NOT NULL
3431 AND strategy_id IS NOT NULL
3432 AND account_id IS NOT NULL
3433 AND instrument_id IS NOT NULL
3434 AND pool_address IS NOT NULL
3435 AND amount_in IS NOT NULL
3436 )
3437 ),
3438 UNIQUE (id, chain_id)
3439 )
3440 ",
3441 "
3442 CREATE UNIQUE INDEX IF NOT EXISTS execution_intent_active_signer_key
3443 ON execution_intent (chain_id, wallet_address)
3444 WHERE active
3445 ",
3446 "
3447 CREATE UNIQUE INDEX IF NOT EXISTS execution_intent_active_nonce_key
3448 ON execution_intent (chain_id, wallet_address, nonce)
3449 WHERE active AND nonce IS NOT NULL
3450 ",
3451 "
3452 CREATE UNIQUE INDEX IF NOT EXISTS execution_intent_client_order_key
3453 ON execution_intent (chain_id, wallet_address, client_order_id)
3454 WHERE client_order_id IS NOT NULL
3455 ",
3456 "
3457 ALTER TABLE execution_intent
3458 DROP CONSTRAINT IF EXISTS execution_intent_active_check
3459 ",
3460 "
3461 ALTER TABLE execution_intent
3462 ADD CONSTRAINT execution_intent_active_check CHECK (
3463 NOT active
3464 OR status NOT IN ('finalized', 'reverted', 'recoverable')
3465 OR (
3466 status IN ('finalized', 'reverted')
3467 AND NOT (fill_emitted OR terminal_emitted)
3468 )
3469 )
3470 ",
3471 "
3472 CREATE TABLE IF NOT EXISTS execution_transaction_hash (
3473 id BIGSERIAL PRIMARY KEY,
3474 intent_id BIGINT NOT NULL,
3475 chain_id INTEGER NOT NULL,
3476 transaction_hash TEXT NOT NULL,
3477 payload_expected BOOLEAN NOT NULL DEFAULT TRUE,
3478 raw_transaction BYTEA,
3479 sealed_transaction BYTEA,
3480 status TEXT NOT NULL,
3481 block_number BIGINT,
3482 block_hash TEXT,
3483 receipt_success BOOLEAN,
3484 gas_used BIGINT,
3485 effective_gas_price TEXT,
3486 current BOOLEAN NOT NULL DEFAULT TRUE,
3487 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3488 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3489 FOREIGN KEY (intent_id, chain_id)
3490 REFERENCES execution_intent(id, chain_id) ON DELETE RESTRICT,
3491 CHECK (block_number IS NULL OR block_number >= 0),
3492 CHECK (gas_used IS NULL OR gas_used >= 0),
3493 CONSTRAINT execution_transaction_raw_size_check
3494 CHECK (raw_transaction IS NULL OR octet_length(raw_transaction) <= 131072),
3495 CONSTRAINT execution_transaction_sealed_size_check
3496 CHECK (sealed_transaction IS NULL OR octet_length(sealed_transaction) <= 131133),
3497 CHECK (status IN (
3498 'signed', 'broadcast', 'included', 'finalized', 'reverted',
3499 'replaced', 'dropped', 'reorged'
3500 )),
3501 UNIQUE (chain_id, transaction_hash),
3502 UNIQUE (intent_id, transaction_hash)
3503 )
3504 ",
3505 "
3506 ALTER TABLE execution_transaction_hash
3507 ADD COLUMN IF NOT EXISTS payload_expected BOOLEAN
3508 ",
3509 "
3510 UPDATE execution_transaction_hash
3511 SET payload_expected = raw_transaction IS NOT NULL
3512 WHERE payload_expected IS NULL
3513 ",
3514 "
3515 ALTER TABLE execution_transaction_hash
3516 ALTER COLUMN payload_expected SET DEFAULT TRUE
3517 ",
3518 "
3519 ALTER TABLE execution_transaction_hash
3520 ALTER COLUMN payload_expected SET NOT NULL
3521 ",
3522 "
3523 ALTER TABLE execution_transaction_hash
3524 ADD COLUMN IF NOT EXISTS sealed_transaction BYTEA
3525 ",
3526 "
3527 ALTER TABLE execution_transaction_hash
3528 DROP CONSTRAINT IF EXISTS execution_transaction_raw_size_check
3529 ",
3530 "
3531 ALTER TABLE execution_transaction_hash
3532 ADD CONSTRAINT execution_transaction_raw_size_check
3533 CHECK (raw_transaction IS NULL OR octet_length(raw_transaction) <= 131072)
3534 ",
3535 "
3536 ALTER TABLE execution_transaction_hash
3537 DROP CONSTRAINT IF EXISTS execution_transaction_sealed_size_check
3538 ",
3539 "
3540 ALTER TABLE execution_transaction_hash
3541 ADD CONSTRAINT execution_transaction_sealed_size_check
3542 CHECK (sealed_transaction IS NULL OR octet_length(sealed_transaction) <= 131133)
3543 ",
3544 "
3545 CREATE TABLE IF NOT EXISTS execution_payload_state (
3546 component TEXT PRIMARY KEY CHECK (component = 'signed_transactions'),
3547 deployment_id TEXT NOT NULL CHECK (deployment_id <> ''),
3548 protocol_version SMALLINT NOT NULL CHECK (protocol_version = 1),
3549 operation TEXT NOT NULL CHECK (operation IN ('migrate', 'ready', 'rewrap', 'rollback')),
3550 active_key_id BYTEA NOT NULL CHECK (octet_length(active_key_id) = 32),
3551 progress_id BIGINT NOT NULL DEFAULT 0 CHECK (progress_id >= 0),
3552 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
3553 )
3554 ",
3555 "
3556 CREATE TABLE IF NOT EXISTS execution_payload_key_state (
3557 key_id BYTEA PRIMARY KEY CHECK (octet_length(key_id) = 32),
3558 seals BIGINT NOT NULL DEFAULT 0 CHECK (seals >= 0 AND seals < 4294967296)
3559 )
3560 ",
3561 "
3562 CREATE UNIQUE INDEX IF NOT EXISTS execution_transaction_hash_current_key
3563 ON execution_transaction_hash (intent_id)
3564 WHERE current
3565 ",
3566 "
3567 CREATE TABLE IF NOT EXISTS execution_transaction_transition (
3568 id BIGSERIAL PRIMARY KEY,
3569 intent_id BIGINT NOT NULL REFERENCES execution_intent(id) ON DELETE RESTRICT,
3570 transaction_hash_id BIGINT REFERENCES execution_transaction_hash(id) ON DELETE RESTRICT,
3571 transition_key TEXT NOT NULL,
3572 from_status TEXT,
3573 to_status TEXT NOT NULL,
3574 block_number BIGINT,
3575 block_hash TEXT,
3576 observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3577 CHECK (block_number IS NULL OR block_number >= 0),
3578 CHECK (to_status IN (
3579 'prepared', 'signed', 'broadcast', 'included', 'finalized',
3580 'reverted', 'replaced', 'dropped', 'reorged', 'recoverable'
3581 )),
3582 UNIQUE (intent_id, transition_key)
3583 )
3584 ",
3585 ] {
3586 sqlx::query(statement)
3587 .execute(&mut *transaction)
3588 .await
3589 .map_err(|e| {
3590 anyhow::anyhow!("Failed to migrate execution_transaction table: {e}")
3591 })?;
3592 }
3593
3594 let installed_version = sqlx::query_scalar::<_, i16>(
3595 "SELECT version FROM execution_schema_version WHERE component = 'evm_execution'",
3596 )
3597 .fetch_optional(&mut *transaction)
3598 .await
3599 .map_err(|e| anyhow::anyhow!("Failed to read execution schema version: {e}"))?;
3600 if let Some(installed_version) = installed_version
3601 && installed_version > EXECUTION_SCHEMA_VERSION
3602 {
3603 anyhow::bail!(
3604 "Execution schema version {} is newer than supported version {EXECUTION_SCHEMA_VERSION}",
3605 installed_version
3606 );
3607 }
3608
3609 let unresolved_legacy = sqlx::query_scalar::<_, i64>(
3610 "
3611 SELECT COUNT(*)
3612 FROM execution_transaction
3613 WHERE status IN ('pending', 'included', 'reverted')
3614 ",
3615 )
3616 .fetch_one(&mut *transaction)
3617 .await
3618 .map_err(|e| anyhow::anyhow!("Failed to inspect legacy execution transactions: {e}"))?;
3619 anyhow::ensure!(
3620 unresolved_legacy == 0,
3621 "Cannot safely migrate {unresolved_legacy} unresolved execution schema version 1 transaction(s); resolve them with the prior version before enabling version {EXECUTION_SCHEMA_VERSION}"
3622 );
3623
3624 for statement in [
3625 "
3626 CREATE OR REPLACE FUNCTION execution_transaction_v2_fence()
3627 RETURNS TRIGGER AS $$
3628 BEGIN
3629 RAISE EXCEPTION 'Legacy execution writer refused after schema version 2 activation';
3630 END;
3631 $$ LANGUAGE plpgsql
3632 ",
3633 "DROP TRIGGER IF EXISTS execution_transaction_v2_fence ON execution_transaction",
3634 "
3635 CREATE TRIGGER execution_transaction_v2_fence
3636 BEFORE INSERT OR UPDATE OR DELETE ON execution_transaction
3637 FOR EACH STATEMENT EXECUTE FUNCTION execution_transaction_v2_fence()
3638 ",
3639 "
3640 CREATE OR REPLACE FUNCTION execution_transition_append_only()
3641 RETURNS TRIGGER AS $$
3642 BEGIN
3643 RAISE EXCEPTION 'Execution transitions are append-only';
3644 END;
3645 $$ LANGUAGE plpgsql
3646 ",
3647 "DROP TRIGGER IF EXISTS execution_transition_append_only ON execution_transaction_transition",
3648 "
3649 CREATE TRIGGER execution_transition_append_only
3650 BEFORE UPDATE OR DELETE ON execution_transaction_transition
3651 FOR EACH STATEMENT EXECUTE FUNCTION execution_transition_append_only()
3652 ",
3653 "
3654 INSERT INTO execution_schema_version (component, version)
3655 VALUES ('evm_execution', 2)
3656 ON CONFLICT (component) DO UPDATE SET version = EXCLUDED.version
3657 WHERE execution_schema_version.version <= EXCLUDED.version
3658 ",
3659 ] {
3660 sqlx::query(statement)
3661 .execute(&mut *transaction)
3662 .await
3663 .map_err(|e| {
3664 anyhow::anyhow!("Failed to activate execution schema version 2: {e}")
3665 })?;
3666 }
3667
3668 transaction
3669 .commit()
3670 .await
3671 .map_err(|e| anyhow::anyhow!("Failed to commit execution schema migration: {e}"))?;
3672
3673 Ok(())
3674 }
3675
3676 pub(crate) async fn load_execution_verification_position(
3678 &self,
3679 chain_id: u32,
3680 wallet_address: &str,
3681 manifest_version: &str,
3682 manifest_digest: &str,
3683 ) -> anyhow::Result<Option<ExecutionVerificationPosition>> {
3684 let installed = sqlx::query_scalar::<_, i16>(
3685 "SELECT version FROM execution_schema_version \
3686 WHERE component = 'evm_execution_verification'",
3687 )
3688 .fetch_optional(&self.pool)
3689 .await
3690 .context("failed to inspect execution verification schema")?;
3691 let Some(installed) = installed else {
3692 return Ok(None);
3693 };
3694 anyhow::ensure!(
3695 installed <= VERIFICATION_SCHEMA_VERSION,
3696 "Unsupported execution verification schema version {installed}"
3697 );
3698 let chain_id =
3699 i32::try_from(chain_id).context("Verification chain ID exceeds PostgreSQL INTEGER")?;
3700 let current = sqlx::query_as::<_, (String, String, i64, i64)>(
3701 "
3702 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
3703 FROM execution_verification_nonce
3704 WHERE chain_id = $1 AND wallet_address = $2
3705 ",
3706 )
3707 .bind(chain_id)
3708 .bind(wallet_address)
3709 .fetch_optional(&self.pool)
3710 .await
3711 .context("failed to load execution verification nonce position")?;
3712 let Some((stored_version, stored_digest, nonce, revision)) = current else {
3713 return Ok(None);
3714 };
3715 anyhow::ensure!(
3716 stored_version == manifest_version && stored_digest == manifest_digest,
3717 "Execution verification manifest identity changed"
3718 );
3719 let row = sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
3720 "
3721 SELECT number, hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
3722 FROM execution_verified_finalized_header
3723 WHERE chain_id = $1 AND wallet_address = $2
3724 ORDER BY number DESC
3725 LIMIT 1
3726 ",
3727 )
3728 .bind(chain_id)
3729 .bind(wallet_address)
3730 .fetch_optional(&self.pool)
3731 .await
3732 .context("failed to load verified finalized header tip")?
3733 .ok_or_else(|| anyhow::anyhow!("Verified finalized header ledger is empty"))?;
3734 let (number, hash, parent_hash, timestamp, base_fee, digest) = row;
3735 anyhow::ensure!(
3736 digest == manifest_digest,
3737 "Finalized header manifest identity changed"
3738 );
3739 Ok(Some(ExecutionVerificationPosition {
3740 next_canonical_nonce: u64::try_from(nonce).context("Canonical nonce is negative")?,
3741 revision: u64::try_from(revision).context("Canonical nonce revision is negative")?,
3742 finalized_tip: ExecutionVerifiedHeader {
3743 number: u64::try_from(number).context("Finalized header number is negative")?,
3744 hash,
3745 parent_hash,
3746 timestamp: u64::try_from(timestamp)
3747 .context("Finalized header timestamp is negative")?,
3748 base_fee_per_gas: base_fee
3749 .map(|value| {
3750 value
3751 .parse::<u128>()
3752 .map_err(|_| anyhow::anyhow!("Finalized header base fee is invalid"))
3753 })
3754 .transpose()?,
3755 },
3756 }))
3757 }
3758
3759 pub(crate) async fn load_execution_verified_header(
3760 &self,
3761 chain_id: u32,
3762 wallet_address: &str,
3763 number: u64,
3764 manifest_digest: &str,
3765 ) -> anyhow::Result<Option<ExecutionVerifiedHeader>> {
3766 let chain_id =
3767 i32::try_from(chain_id).context("Verification chain ID exceeds PostgreSQL INTEGER")?;
3768 let number =
3769 i64::try_from(number).context("Finalized header number exceeds PostgreSQL BIGINT")?;
3770 let row = sqlx::query_as::<_, (String, String, i64, Option<String>, String)>(
3771 "
3772 SELECT hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
3773 FROM execution_verified_finalized_header
3774 WHERE chain_id = $1 AND wallet_address = $2 AND number = $3
3775 ",
3776 )
3777 .bind(chain_id)
3778 .bind(wallet_address)
3779 .bind(number)
3780 .fetch_optional(&self.pool)
3781 .await
3782 .context("failed to load verified finalized header")?;
3783 let Some((hash, parent_hash, timestamp, base_fee, digest)) = row else {
3784 return Ok(None);
3785 };
3786 anyhow::ensure!(
3787 digest == manifest_digest,
3788 "Finalized header manifest identity changed"
3789 );
3790 Ok(Some(ExecutionVerifiedHeader {
3791 number: u64::try_from(number).context("Finalized header number is negative")?,
3792 hash,
3793 parent_hash,
3794 timestamp: u64::try_from(timestamp)
3795 .context("Finalized header timestamp is negative")?,
3796 base_fee_per_gas: base_fee
3797 .map(|value| {
3798 value
3799 .parse::<u128>()
3800 .map_err(|_| anyhow::anyhow!("Finalized header base fee is invalid"))
3801 })
3802 .transpose()?,
3803 }))
3804 }
3805
3806 pub(crate) async fn load_execution_verification_migration_snapshot(
3808 &self,
3809 chain_id: u32,
3810 wallet_address: &str,
3811 ) -> anyhow::Result<ExecutionVerificationMigrationSnapshot> {
3812 let chain_id =
3813 i32::try_from(chain_id).context("Verification chain ID exceeds PostgreSQL INTEGER")?;
3814 let intents = sqlx::query_as::<_, ExecutionIntentRow>(
3815 "
3816 SELECT
3817 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
3818 client_order_id, trader_id, strategy_id, account_id, instrument_id,
3819 pool_address, transaction_to, transaction_input, transaction_value,
3820 amount_in, created_block, acknowledgement_emitted, fill_emitted,
3821 terminal_emitted, active
3822 FROM execution_intent
3823 WHERE chain_id = $1 AND wallet_address = $2
3824 ORDER BY id
3825 ",
3826 )
3827 .bind(chain_id)
3828 .bind(wallet_address)
3829 .fetch_all(&self.pool)
3830 .await
3831 .context("failed to load retained execution intents for verification migration")?;
3832 let hashes = sqlx::query_as::<_, ExecutionTransactionHashRow>(
3833 "
3834 SELECT
3835 hash.id, hash.intent_id, hash.chain_id, hash.transaction_hash,
3836 hash.payload_expected, hash.raw_transaction, hash.sealed_transaction,
3837 hash.status, hash.block_number, hash.block_hash, hash.receipt_success,
3838 hash.gas_used, hash.effective_gas_price, hash.current
3839 FROM execution_transaction_hash AS hash
3840 JOIN execution_intent AS intent ON intent.id = hash.intent_id
3841 WHERE intent.chain_id = $1 AND intent.wallet_address = $2
3842 ORDER BY hash.id
3843 ",
3844 )
3845 .bind(chain_id)
3846 .bind(wallet_address)
3847 .fetch_all(&self.pool)
3848 .await
3849 .context("failed to load retained execution hashes for verification migration")?;
3850 Ok(ExecutionVerificationMigrationSnapshot { intents, hashes })
3851 }
3852
3853 pub(crate) async fn ensure_execution_verification_schema(
3865 &self,
3866 bootstrap: &ExecutionVerificationBootstrap<'_>,
3867 ) -> anyhow::Result<()> {
3868 let first_header = bootstrap
3869 .finalized_headers
3870 .first()
3871 .ok_or_else(|| anyhow::anyhow!("Verified finalized header ledger is empty"))?;
3872 anyhow::ensure!(
3873 bootstrap.finalized_headers.windows(2).all(|headers| {
3874 headers[1].number == headers[0].number.saturating_add(1)
3875 && headers[1].parent_hash == headers[0].hash
3876 }),
3877 "Verified finalized headers are not one continuous parent-linked chain"
3878 );
3879 anyhow::ensure!(
3880 bootstrap.provider_ids.len() == 3
3881 && bootstrap.operator_ids.len() == 3
3882 && bootstrap.failure_domain_ids.len() >= 3
3883 && !bootstrap.decisions.is_empty(),
3884 "Connect verification evidence is incomplete"
3885 );
3886 let chain_id = i32::try_from(bootstrap.chain_id)
3887 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
3888 let checkpoint_number = i64::try_from(bootstrap.checkpoint_number)
3889 .context("Verification checkpoint exceeds PostgreSQL BIGINT")?;
3890 let checkpoint_timestamp = i64::try_from(bootstrap.checkpoint_timestamp)
3891 .context("Verification checkpoint timestamp exceeds PostgreSQL BIGINT")?;
3892 let next_canonical_nonce = i64::try_from(bootstrap.next_canonical_nonce)
3893 .context("Canonical nonce exceeds PostgreSQL BIGINT")?;
3894 let observed_canonical_nonce = i64::try_from(bootstrap.observed_canonical_nonce)
3895 .context("Observed canonical nonce exceeds PostgreSQL BIGINT")?;
3896 let base_fee = bootstrap
3897 .checkpoint_base_fee_per_gas
3898 .map(|value| value.to_string());
3899 let mut transaction = self.pool.begin().await.map_err(|e| {
3900 anyhow::anyhow!("Failed to start execution verification migration: {e}")
3901 })?;
3902
3903 for statement in [
3904 "
3905 CREATE TABLE IF NOT EXISTS execution_verification_nonce (
3906 chain_id INTEGER NOT NULL REFERENCES chain(chain_id) ON DELETE RESTRICT,
3907 wallet_address TEXT NOT NULL,
3908 manifest_version TEXT NOT NULL CHECK (manifest_version <> ''),
3909 manifest_digest TEXT NOT NULL CHECK (manifest_digest <> ''),
3910 next_canonical_nonce BIGINT NOT NULL CHECK (next_canonical_nonce >= 0),
3911 revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
3912 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3913 PRIMARY KEY (chain_id, wallet_address)
3914 )
3915 ",
3916 "
3917 CREATE TABLE IF NOT EXISTS execution_verified_finalized_header (
3918 chain_id INTEGER NOT NULL,
3919 wallet_address TEXT NOT NULL,
3920 number BIGINT NOT NULL CHECK (number >= 0),
3921 hash TEXT NOT NULL CHECK (hash <> ''),
3922 parent_hash TEXT NOT NULL CHECK (parent_hash <> ''),
3923 timestamp BIGINT NOT NULL CHECK (timestamp >= 0),
3924 base_fee_per_gas TEXT,
3925 manifest_digest TEXT NOT NULL CHECK (manifest_digest <> ''),
3926 observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3927 PRIMARY KEY (chain_id, wallet_address, number),
3928 UNIQUE (chain_id, wallet_address, hash),
3929 FOREIGN KEY (chain_id, wallet_address)
3930 REFERENCES execution_verification_nonce(chain_id, wallet_address)
3931 ON DELETE RESTRICT
3932 )
3933 ",
3934 "
3935 CREATE TABLE IF NOT EXISTS execution_verification_decision (
3936 id BIGSERIAL PRIMARY KEY,
3937 intent_id BIGINT REFERENCES execution_intent(id) ON DELETE RESTRICT,
3938 nonce BIGINT CHECK (nonce IS NULL OR nonce >= 0),
3939 decision_class TEXT NOT NULL CHECK (decision_class <> ''),
3940 read_class TEXT NOT NULL CHECK (read_class <> ''),
3941 height_start BIGINT CHECK (height_start IS NULL OR height_start >= 0),
3942 height_end BIGINT CHECK (height_end IS NULL OR height_end >= 0),
3943 manifest_version TEXT NOT NULL CHECK (manifest_version <> ''),
3944 manifest_digest TEXT NOT NULL CHECK (manifest_digest <> ''),
3945 provider_ids TEXT[] NOT NULL,
3946 operator_ids TEXT[] NOT NULL,
3947 failure_domain_ids TEXT[] NOT NULL,
3948 response_class TEXT NOT NULL CHECK (response_class <> ''),
3949 normalized_value_digest TEXT,
3950 safe_value JSONB,
3951 nonce_revision BIGINT CHECK (nonce_revision IS NULL OR nonce_revision >= 0),
3952 outcome TEXT NOT NULL CHECK (outcome IN (
3953 'verified', 'disagreement', 'unavailable', 'retryable', 'locally_invalid'
3954 )),
3955 transition_key TEXT,
3956 observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3957 CHECK (cardinality(provider_ids) = 3),
3958 CHECK (cardinality(operator_ids) = 3),
3959 CHECK (cardinality(failure_domain_ids) >= 3),
3960 CHECK (safe_value IS NULL OR pg_column_size(safe_value) <= 16384),
3961 UNIQUE (intent_id, transition_key)
3962 )
3963 ",
3964 "
3965 CREATE TABLE IF NOT EXISTS execution_replacement_scan (
3966 intent_id BIGINT PRIMARY KEY REFERENCES execution_intent(id) ON DELETE RESTRICT,
3967 chain_id INTEGER NOT NULL,
3968 wallet_address TEXT NOT NULL,
3969 nonce BIGINT NOT NULL CHECK (nonce >= 0),
3970 finalized_cursor_number BIGINT NOT NULL CHECK (finalized_cursor_number >= 0),
3971 finalized_cursor_hash TEXT NOT NULL CHECK (finalized_cursor_hash <> ''),
3972 manifest_digest TEXT NOT NULL CHECK (manifest_digest <> ''),
3973 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3974 UNIQUE (chain_id, wallet_address, nonce)
3975 )
3976 ",
3977 ] {
3978 sqlx::query(statement)
3979 .execute(&mut *transaction)
3980 .await
3981 .map_err(|e| anyhow::anyhow!("Failed to install verification schema: {e}"))?;
3982 }
3983
3984 sqlx::query(
3985 "LOCK TABLE execution_intent, execution_transaction_hash, \
3986 execution_verification_nonce, execution_verified_finalized_header, \
3987 execution_verification_decision, execution_replacement_scan \
3988 IN ACCESS EXCLUSIVE MODE",
3989 )
3990 .execute(&mut *transaction)
3991 .await
3992 .map_err(|e| anyhow::anyhow!("Failed to lock execution verification state: {e}"))?;
3993
3994 let installed_version = sqlx::query_scalar::<_, i16>(
3995 "SELECT version FROM execution_schema_version \
3996 WHERE component = 'evm_execution_verification'",
3997 )
3998 .fetch_optional(&mut *transaction)
3999 .await
4000 .map_err(|e| anyhow::anyhow!("Failed to read verification schema version: {e}"))?;
4001 if let Some(installed_version) = installed_version {
4002 anyhow::ensure!(
4003 installed_version <= VERIFICATION_SCHEMA_VERSION,
4004 "Execution verification schema version {installed_version} is newer than supported version {VERIFICATION_SCHEMA_VERSION}"
4005 );
4006 }
4007
4008 let current = sqlx::query_as::<_, (String, String, i64, i64)>(
4009 "
4010 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
4011 FROM execution_verification_nonce
4012 WHERE chain_id = $1 AND wallet_address = $2
4013 FOR UPDATE
4014 ",
4015 )
4016 .bind(chain_id)
4017 .bind(bootstrap.wallet_address)
4018 .fetch_optional(&mut *transaction)
4019 .await
4020 .map_err(|e| anyhow::anyhow!("Failed to read canonical nonce ledger: {e}"))?;
4021 let initialized = current.is_some();
4022
4023 let revision = if let Some((manifest_version, manifest_digest, stored_nonce, revision)) =
4024 current
4025 {
4026 anyhow::ensure!(
4027 bootstrap.migration.is_none(),
4028 "Verification migration was supplied for an initialized signer"
4029 );
4030 anyhow::ensure!(
4031 manifest_version == bootstrap.manifest_version
4032 && manifest_digest == bootstrap.manifest_digest,
4033 "Execution verification manifest identity changed"
4034 );
4035 anyhow::ensure!(
4036 stored_nonce == next_canonical_nonce,
4037 "Canonical nonce ledger changed during verification bootstrap"
4038 );
4039
4040 if observed_canonical_nonce != stored_nonce {
4041 let expected_observed_nonce = stored_nonce
4042 .checked_add(1)
4043 .ok_or_else(|| anyhow::anyhow!("Canonical nonce overflow"))?;
4044 anyhow::ensure!(
4045 observed_canonical_nonce == expected_observed_nonce,
4046 "Verified finalized transaction count is outside the owned recovery range"
4047 );
4048 let recovery = sqlx::query_as::<_, (Option<i64>, String, i64)>(
4049 "
4050 SELECT
4051 intent.nonce,
4052 intent.status,
4053 COUNT(hash.id) FILTER (
4054 WHERE hash.current
4055 AND hash.payload_expected
4056 AND ((hash.raw_transaction IS NOT NULL)::INTEGER
4057 + (hash.sealed_transaction IS NOT NULL)::INTEGER) = 1
4058 AND hash.status IN (
4059 'broadcast', 'included', 'replaced', 'dropped', 'reorged'
4060 )
4061 )
4062 FROM execution_intent AS intent
4063 LEFT JOIN execution_transaction_hash AS hash
4064 ON hash.intent_id = intent.id
4065 WHERE intent.chain_id = $1
4066 AND intent.wallet_address = $2
4067 AND intent.active
4068 GROUP BY intent.id, intent.nonce, intent.status
4069 ",
4070 )
4071 .bind(chain_id)
4072 .bind(bootstrap.wallet_address)
4073 .fetch_optional(&mut *transaction)
4074 .await
4075 .map_err(|e| anyhow::anyhow!("Failed to validate nonce recovery ownership: {e}"))?;
4076 let Some((intent_nonce, intent_status, payload_count)) = recovery else {
4077 anyhow::bail!(
4078 "Verified finalized transaction count advanced without an active owned intent"
4079 );
4080 };
4081 anyhow::ensure!(
4082 intent_nonce == Some(stored_nonce)
4083 && matches!(
4084 intent_status.as_str(),
4085 "broadcast" | "included" | "replaced" | "dropped" | "reorged"
4086 )
4087 && payload_count == 1,
4088 "Verified finalized transaction count advanced without one recoverable retained payload at the durable nonce"
4089 );
4090 }
4091 revision
4092 } else {
4093 anyhow::ensure!(
4094 next_canonical_nonce == observed_canonical_nonce,
4095 "Initial canonical nonce conflicts with the verified finalized transaction count"
4096 );
4097 let locked_intents = sqlx::query_as::<_, ExecutionIntentRow>(
4098 "
4099 SELECT
4100 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
4101 client_order_id, trader_id, strategy_id, account_id, instrument_id,
4102 pool_address, transaction_to, transaction_input, transaction_value,
4103 amount_in, created_block, acknowledgement_emitted, fill_emitted,
4104 terminal_emitted, active
4105 FROM execution_intent
4106 WHERE chain_id = $1 AND wallet_address = $2
4107 ORDER BY id
4108 ",
4109 )
4110 .bind(chain_id)
4111 .bind(bootstrap.wallet_address)
4112 .fetch_all(&mut *transaction)
4113 .await
4114 .map_err(|e| anyhow::anyhow!("Failed to lock retained execution intents: {e}"))?;
4115 let locked_hashes = sqlx::query_as::<_, ExecutionTransactionHashRow>(
4116 "
4117 SELECT
4118 hash.id, hash.intent_id, hash.chain_id, hash.transaction_hash,
4119 hash.payload_expected, hash.raw_transaction, hash.sealed_transaction,
4120 hash.status, hash.block_number, hash.block_hash, hash.receipt_success,
4121 hash.gas_used, hash.effective_gas_price, hash.current
4122 FROM execution_transaction_hash AS hash
4123 JOIN execution_intent AS intent ON intent.id = hash.intent_id
4124 WHERE intent.chain_id = $1 AND intent.wallet_address = $2
4125 ORDER BY hash.id
4126 ",
4127 )
4128 .bind(chain_id)
4129 .bind(bootstrap.wallet_address)
4130 .fetch_all(&mut *transaction)
4131 .await
4132 .map_err(|e| anyhow::anyhow!("Failed to lock retained execution hashes: {e}"))?;
4133 if locked_intents.is_empty() {
4134 anyhow::ensure!(
4135 bootstrap.migration.is_none(),
4136 "Verification migration was supplied for empty execution history"
4137 );
4138 } else {
4139 let migration = bootstrap.migration.ok_or_else(|| {
4140 anyhow::anyhow!(
4141 "Retained execution history requires archive verification before migration"
4142 )
4143 })?;
4144 anyhow::ensure!(
4145 migration.snapshot.intents == locked_intents
4146 && migration.snapshot.hashes == locked_hashes,
4147 "Retained execution history changed during verification migration"
4148 );
4149 let intent_ids = locked_intents
4150 .iter()
4151 .map(|intent| intent.id)
4152 .collect::<BTreeSet<_>>();
4153 let record_ids = migration
4154 .records
4155 .iter()
4156 .map(|record| record.intent_id)
4157 .collect::<BTreeSet<_>>();
4158 anyhow::ensure!(
4159 intent_ids == record_ids && record_ids.len() == migration.records.len(),
4160 "Verification migration does not classify every retained intent exactly once"
4161 );
4162 }
4163 sqlx::query(
4164 "
4165 INSERT INTO execution_verification_nonce (
4166 chain_id, wallet_address, manifest_version, manifest_digest,
4167 next_canonical_nonce
4168 )
4169 VALUES ($1, $2, $3, $4, $5)
4170 ",
4171 )
4172 .bind(chain_id)
4173 .bind(bootstrap.wallet_address)
4174 .bind(bootstrap.manifest_version)
4175 .bind(bootstrap.manifest_digest)
4176 .bind(next_canonical_nonce)
4177 .execute(&mut *transaction)
4178 .await
4179 .map_err(|e| anyhow::anyhow!("Failed to initialize canonical nonce ledger: {e}"))?;
4180
4181 if let Some(migration) = bootstrap.migration {
4182 for record in &migration.records {
4183 anyhow::ensure!(
4184 !record.decisions.is_empty(),
4185 "Verification migration evidence is empty for intent {}",
4186 record.intent_id
4187 );
4188
4189 if record.recover_prepared {
4190 anyhow::ensure!(
4191 record.terminal_status.is_none()
4192 && record.transaction_hash.is_none()
4193 && record.nonce.is_none(),
4194 "Prepared recovery migration record is inconsistent"
4195 );
4196 let result = sqlx::query(
4197 "
4198 UPDATE execution_intent
4199 SET status = 'recoverable', active = FALSE, updated_at = NOW()
4200 WHERE id = $1 AND active AND status = 'prepared' AND nonce IS NULL
4201 AND NOT EXISTS (
4202 SELECT 1 FROM execution_transaction_hash WHERE intent_id = $1
4203 )
4204 ",
4205 )
4206 .bind(record.intent_id)
4207 .execute(&mut *transaction)
4208 .await
4209 .map_err(|e| {
4210 anyhow::anyhow!("Failed to recover migrated prepared intent: {e}")
4211 })?;
4212 anyhow::ensure!(
4213 result.rows_affected() == 1,
4214 "Prepared intent {} changed during verification migration",
4215 record.intent_id
4216 );
4217 }
4218
4219 if let Some(status) = record.terminal_status {
4220 anyhow::ensure!(
4221 matches!(
4222 status,
4223 TransactionStatus::Finalized | TransactionStatus::Reverted
4224 ),
4225 "Migration terminal status is not finalized or reverted"
4226 );
4227 let transaction_hash =
4228 record.transaction_hash.as_deref().ok_or_else(|| {
4229 anyhow::anyhow!("Terminal migration record has no transaction hash")
4230 })?;
4231 let block_number = i64::try_from(record.block_number.ok_or_else(|| {
4232 anyhow::anyhow!("Terminal migration record has no block number")
4233 })?)
4234 .context("Migration block exceeds PostgreSQL BIGINT")?;
4235 let block_hash = record.block_hash.as_deref().ok_or_else(|| {
4236 anyhow::anyhow!("Terminal migration record has no block hash")
4237 })?;
4238 let gas_used = i64::try_from(record.gas_used.ok_or_else(|| {
4239 anyhow::anyhow!("Terminal migration record has no gas usage")
4240 })?)
4241 .context("Migration gas usage exceeds PostgreSQL BIGINT")?;
4242 let receipt_success = record.receipt_success.ok_or_else(|| {
4243 anyhow::anyhow!("Terminal migration record has no receipt status")
4244 })?;
4245 anyhow::ensure!(
4246 receipt_success == (status == TransactionStatus::Finalized),
4247 "Migration receipt status conflicts with terminal status"
4248 );
4249 let current_status = sqlx::query_scalar::<_, String>(
4250 "SELECT status FROM execution_intent WHERE id = $1 FOR UPDATE",
4251 )
4252 .bind(record.intent_id)
4253 .fetch_one(&mut *transaction)
4254 .await
4255 .map_err(|e| anyhow::anyhow!("Failed to lock migrated intent: {e}"))?;
4256 let hash_result = sqlx::query(
4257 "
4258 UPDATE execution_transaction_hash
4259 SET status = $3, block_number = $4, block_hash = $5,
4260 receipt_success = $6, gas_used = $7,
4261 effective_gas_price = $8, updated_at = NOW()
4262 WHERE intent_id = $1 AND transaction_hash = $2 AND current
4263 AND payload_expected
4264 ",
4265 )
4266 .bind(record.intent_id)
4267 .bind(transaction_hash)
4268 .bind(status.as_str())
4269 .bind(block_number)
4270 .bind(block_hash)
4271 .bind(receipt_success)
4272 .bind(gas_used)
4273 .bind(record.effective_gas_price.as_deref())
4274 .execute(&mut *transaction)
4275 .await
4276 .map_err(|e| anyhow::anyhow!("Failed to reconstruct migrated hash: {e}"))?;
4277 anyhow::ensure!(
4278 hash_result.rows_affected() == 1,
4279 "Migrated terminal transaction hash was not found"
4280 );
4281 sqlx::query(
4282 "UPDATE execution_intent SET status = $2, updated_at = NOW() WHERE id = $1",
4283 )
4284 .bind(record.intent_id)
4285 .bind(status.as_str())
4286 .execute(&mut *transaction)
4287 .await
4288 .map_err(|e| anyhow::anyhow!("Failed to reconstruct migrated intent: {e}"))?;
4289 if current_status != status.as_str() {
4290 sqlx::query(
4291 "
4292 INSERT INTO execution_transaction_transition (
4293 intent_id, transaction_hash_id, transition_key,
4294 from_status, to_status, block_number, block_hash
4295 )
4296 SELECT $1, id, $3, $4, $5, $6, $7
4297 FROM execution_transaction_hash
4298 WHERE intent_id = $1 AND transaction_hash = $2
4299 ",
4300 )
4301 .bind(record.intent_id)
4302 .bind(transaction_hash)
4303 .bind(format!("migration:{}:{transaction_hash}", status.as_str()))
4304 .bind(current_status)
4305 .bind(status.as_str())
4306 .bind(block_number)
4307 .bind(block_hash)
4308 .execute(&mut *transaction)
4309 .await
4310 .map_err(|e| {
4311 anyhow::anyhow!(
4312 "Failed to record migrated terminal transition: {e}"
4313 )
4314 })?;
4315 }
4316 }
4317
4318 let nonce = record
4319 .nonce
4320 .map(i64::try_from)
4321 .transpose()
4322 .context("Migration nonce exceeds PostgreSQL BIGINT")?;
4323
4324 for (index, decision) in record.decisions.iter().enumerate() {
4325 let height_start = decision
4326 .height_start
4327 .map(i64::try_from)
4328 .transpose()
4329 .context("Migration evidence height exceeds PostgreSQL BIGINT")?;
4330 let height_end = decision
4331 .height_end
4332 .map(i64::try_from)
4333 .transpose()
4334 .context("Migration evidence height exceeds PostgreSQL BIGINT")?;
4335 sqlx::query(
4336 "
4337 INSERT INTO execution_verification_decision (
4338 intent_id, nonce, decision_class, read_class,
4339 height_start, height_end, manifest_version, manifest_digest,
4340 provider_ids, operator_ids, failure_domain_ids,
4341 response_class, normalized_value_digest, nonce_revision,
4342 outcome, transition_key
4343 )
4344 VALUES (
4345 $1, $2, 'migration', $3, $4, $5, $6, $7, $8, $9, $10,
4346 'all_valid', $11, 0, 'verified', $12
4347 )
4348 ",
4349 )
4350 .bind(record.intent_id)
4351 .bind(nonce)
4352 .bind(decision.read_class)
4353 .bind(height_start)
4354 .bind(height_end)
4355 .bind(bootstrap.manifest_version)
4356 .bind(bootstrap.manifest_digest)
4357 .bind(bootstrap.provider_ids)
4358 .bind(bootstrap.operator_ids)
4359 .bind(bootstrap.failure_domain_ids)
4360 .bind(&decision.normalized_value_digest)
4361 .bind(format!("migration:{}:{index}", record.intent_id))
4362 .execute(&mut *transaction)
4363 .await
4364 .map_err(|e| {
4365 anyhow::anyhow!("Failed to persist migration evidence: {e}")
4366 })?;
4367 }
4368 }
4369 }
4370 0
4371 };
4372
4373 sqlx::query(
4374 "
4375 INSERT INTO execution_verified_finalized_header (
4376 chain_id, wallet_address, number, hash, parent_hash, timestamp,
4377 base_fee_per_gas, manifest_digest
4378 )
4379 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
4380 ON CONFLICT (chain_id, wallet_address, number) DO NOTHING
4381 ",
4382 )
4383 .bind(chain_id)
4384 .bind(bootstrap.wallet_address)
4385 .bind(checkpoint_number)
4386 .bind(bootstrap.checkpoint_hash)
4387 .bind(bootstrap.checkpoint_parent_hash)
4388 .bind(checkpoint_timestamp)
4389 .bind(base_fee)
4390 .bind(bootstrap.manifest_digest)
4391 .execute(&mut *transaction)
4392 .await
4393 .map_err(|e| anyhow::anyhow!("Failed to initialize finalized header ledger: {e}"))?;
4394 let stored_checkpoint = sqlx::query_as::<_, (String, String, i64, Option<String>)>(
4395 "
4396 SELECT hash, parent_hash, timestamp, base_fee_per_gas
4397 FROM execution_verified_finalized_header
4398 WHERE chain_id = $1 AND wallet_address = $2 AND number = $3
4399 ",
4400 )
4401 .bind(chain_id)
4402 .bind(bootstrap.wallet_address)
4403 .bind(checkpoint_number)
4404 .fetch_one(&mut *transaction)
4405 .await
4406 .map_err(|e| anyhow::anyhow!("Failed to validate finalized checkpoint ledger: {e}"))?;
4407 anyhow::ensure!(
4408 stored_checkpoint
4409 == (
4410 bootstrap.checkpoint_hash.to_string(),
4411 bootstrap.checkpoint_parent_hash.to_string(),
4412 checkpoint_timestamp,
4413 bootstrap
4414 .checkpoint_base_fee_per_gas
4415 .map(|value| value.to_string()),
4416 ),
4417 "Finalized checkpoint ledger conflicts with the trusted chain anchor"
4418 );
4419
4420 if initialized {
4421 let stored_tip =
4422 sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
4423 "
4424 SELECT number, hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
4425 FROM execution_verified_finalized_header
4426 WHERE chain_id = $1 AND wallet_address = $2
4427 ORDER BY number DESC
4428 LIMIT 1
4429 ",
4430 )
4431 .bind(chain_id)
4432 .bind(bootstrap.wallet_address)
4433 .fetch_one(&mut *transaction)
4434 .await
4435 .map_err(|e| anyhow::anyhow!("Failed to lock finalized header tip: {e}"))?;
4436 anyhow::ensure!(
4437 stored_tip
4438 == (
4439 i64::try_from(first_header.number)
4440 .context("Verified finalized height exceeds PostgreSQL BIGINT")?,
4441 first_header.hash.clone(),
4442 first_header.parent_hash.clone(),
4443 i64::try_from(first_header.timestamp)
4444 .context("Verified finalized timestamp exceeds PostgreSQL BIGINT")?,
4445 first_header.base_fee_per_gas.map(|value| value.to_string()),
4446 bootstrap.manifest_digest.to_string(),
4447 ),
4448 "Verified finalized header extension does not start at the durable tip"
4449 );
4450 } else {
4451 anyhow::ensure!(
4452 first_header.number == bootstrap.checkpoint_number
4453 && first_header.hash == bootstrap.checkpoint_hash
4454 && first_header.parent_hash == bootstrap.checkpoint_parent_hash
4455 && first_header.timestamp == bootstrap.checkpoint_timestamp
4456 && first_header.base_fee_per_gas == bootstrap.checkpoint_base_fee_per_gas,
4457 "Verified finalized headers do not start at the trusted checkpoint"
4458 );
4459 }
4460
4461 for header in bootstrap.finalized_headers.iter().skip(1) {
4462 let number = i64::try_from(header.number)
4463 .context("Verified finalized height exceeds PostgreSQL BIGINT")?;
4464 let timestamp = i64::try_from(header.timestamp)
4465 .context("Verified finalized timestamp exceeds PostgreSQL BIGINT")?;
4466 let base_fee = header.base_fee_per_gas.map(|value| value.to_string());
4467 sqlx::query(
4468 "
4469 INSERT INTO execution_verified_finalized_header (
4470 chain_id, wallet_address, number, hash, parent_hash, timestamp,
4471 base_fee_per_gas, manifest_digest
4472 )
4473 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
4474 ON CONFLICT (chain_id, wallet_address, number) DO NOTHING
4475 ",
4476 )
4477 .bind(chain_id)
4478 .bind(bootstrap.wallet_address)
4479 .bind(number)
4480 .bind(&header.hash)
4481 .bind(&header.parent_hash)
4482 .bind(timestamp)
4483 .bind(&base_fee)
4484 .bind(bootstrap.manifest_digest)
4485 .execute(&mut *transaction)
4486 .await
4487 .map_err(|e| anyhow::anyhow!("Failed to extend finalized header ledger: {e}"))?;
4488 let stored = sqlx::query_as::<_, (String, String, i64, Option<String>, String)>(
4489 "
4490 SELECT hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
4491 FROM execution_verified_finalized_header
4492 WHERE chain_id = $1 AND wallet_address = $2 AND number = $3
4493 ",
4494 )
4495 .bind(chain_id)
4496 .bind(bootstrap.wallet_address)
4497 .bind(number)
4498 .fetch_one(&mut *transaction)
4499 .await
4500 .map_err(|e| anyhow::anyhow!("Failed to validate finalized header ledger: {e}"))?;
4501 anyhow::ensure!(
4502 stored
4503 == (
4504 header.hash.clone(),
4505 header.parent_hash.clone(),
4506 timestamp,
4507 base_fee,
4508 bootstrap.manifest_digest.to_string(),
4509 ),
4510 "Finalized header ledger conflicts at height {}",
4511 header.number
4512 );
4513 }
4514
4515 let finalized_height = bootstrap
4516 .finalized_headers
4517 .last()
4518 .expect("verified finalized headers are nonempty")
4519 .number;
4520
4521 for (index, decision) in bootstrap.decisions.iter().enumerate() {
4522 let height_start = decision
4523 .height_start
4524 .map(i64::try_from)
4525 .transpose()
4526 .context("Connect verification height exceeds PostgreSQL BIGINT")?;
4527 let height_end = decision
4528 .height_end
4529 .map(i64::try_from)
4530 .transpose()
4531 .context("Connect verification height exceeds PostgreSQL BIGINT")?;
4532 let transition_key = format!("connect:{finalized_height}:{revision}:{index}");
4533 sqlx::query(
4534 "
4535 INSERT INTO execution_verification_decision (
4536 intent_id, nonce, decision_class, read_class, height_start, height_end,
4537 manifest_version, manifest_digest, provider_ids, operator_ids,
4538 failure_domain_ids, response_class, normalized_value_digest,
4539 nonce_revision, outcome, transition_key
4540 )
4541 VALUES (
4542 NULL, NULL, 'connect', $1, $2, $3, $4, $5, $6, $7, $8,
4543 'all_valid', $9, $10, 'verified', $11
4544 )
4545 ",
4546 )
4547 .bind(decision.read_class)
4548 .bind(height_start)
4549 .bind(height_end)
4550 .bind(bootstrap.manifest_version)
4551 .bind(bootstrap.manifest_digest)
4552 .bind(bootstrap.provider_ids)
4553 .bind(bootstrap.operator_ids)
4554 .bind(bootstrap.failure_domain_ids)
4555 .bind(&decision.normalized_value_digest)
4556 .bind(revision)
4557 .bind(transition_key)
4558 .execute(&mut *transaction)
4559 .await
4560 .map_err(|e| anyhow::anyhow!("Failed to persist connect verification: {e}"))?;
4561 }
4562
4563 for statement in [
4564 "
4565 CREATE OR REPLACE FUNCTION execution_verification_append_only()
4566 RETURNS TRIGGER AS $$
4567 BEGIN
4568 RAISE EXCEPTION 'Execution verification evidence is append-only';
4569 END;
4570 $$ LANGUAGE plpgsql
4571 ",
4572 "DROP TRIGGER IF EXISTS execution_verification_decision_append_only \
4573 ON execution_verification_decision",
4574 "
4575 CREATE TRIGGER execution_verification_decision_append_only
4576 BEFORE UPDATE OR DELETE ON execution_verification_decision
4577 FOR EACH STATEMENT EXECUTE FUNCTION execution_verification_append_only()
4578 ",
4579 "DROP TRIGGER IF EXISTS execution_finalized_header_append_only \
4580 ON execution_verified_finalized_header",
4581 "
4582 CREATE TRIGGER execution_finalized_header_append_only
4583 BEFORE UPDATE OR DELETE ON execution_verified_finalized_header
4584 FOR EACH STATEMENT EXECUTE FUNCTION execution_verification_append_only()
4585 ",
4586 ] {
4587 sqlx::query(statement)
4588 .execute(&mut *transaction)
4589 .await
4590 .map_err(|e| anyhow::anyhow!("Failed to activate verification schema: {e}"))?;
4591 }
4592 sqlx::query(
4593 "
4594 INSERT INTO execution_schema_version (component, version)
4595 VALUES ('evm_execution_verification', $1)
4596 ON CONFLICT (component) DO UPDATE SET version = EXCLUDED.version
4597 WHERE execution_schema_version.version <= EXCLUDED.version
4598 ",
4599 )
4600 .bind(VERIFICATION_SCHEMA_VERSION)
4601 .execute(&mut *transaction)
4602 .await
4603 .map_err(|e| anyhow::anyhow!("Failed to activate verification schema: {e}"))?;
4604
4605 transaction
4606 .commit()
4607 .await
4608 .map_err(|e| anyhow::anyhow!("Failed to commit verification migration: {e}"))?;
4609 Ok(())
4610 }
4611
4612 pub(crate) async fn ensure_execution_payload_storage(
4614 &self,
4615 keys: &PayloadKeySet,
4616 ) -> anyhow::Result<()> {
4617 let marker = self.execution_payload_marker().await?;
4618 match marker {
4619 None => self.activate_execution_payload_storage(keys).await?,
4620 Some(version) => anyhow::ensure!(
4621 version == EXECUTION_PAYLOAD_PROTOCOL_VERSION,
4622 "Execution payload protection version {version} is newer than supported version {EXECUTION_PAYLOAD_PROTOCOL_VERSION}"
4623 ),
4624 }
4625
4626 loop {
4627 let state = self.execution_payload_state().await?.ok_or_else(|| {
4628 anyhow::anyhow!("Execution payload marker exists without durable state")
4629 })?;
4630 validate_execution_payload_state(&state, keys)?;
4631 match state.operation.as_str() {
4632 "migrate" => {
4633 if self
4634 .migrate_execution_payload_batch(keys, EXECUTION_PAYLOAD_BATCH_SIZE)
4635 .await?
4636 {
4637 break;
4638 }
4639 }
4640 "ready" => break,
4641 operation => anyhow::bail!(
4642 "Execution payload storage is in {operation} maintenance; complete or roll back that operation before connecting"
4643 ),
4644 }
4645 }
4646
4647 self.validate_execution_payload_ready(keys).await
4648 }
4649
4650 pub(crate) async fn require_execution_payload_storage(
4652 &self,
4653 keys: &PayloadKeySet,
4654 policy: PayloadPolicy,
4655 batch_size: i64,
4656 ) -> anyhow::Result<ExecutionPayloadLease> {
4657 self.require_execution_payload_storage_ready(keys).await?;
4658 let (check, transaction) = self
4659 .inspect_execution_payload_storage(Some(keys), Some(policy), batch_size)
4660 .await?;
4661 anyhow::ensure!(
4662 check.protected,
4663 "Postgres execution requires protected payload storage"
4664 );
4665 Ok(ExecutionPayloadLease {
4666 _transaction: transaction,
4667 })
4668 }
4669
4670 pub(crate) async fn require_execution_payload_storage_ready(
4672 &self,
4673 keys: &PayloadKeySet,
4674 ) -> anyhow::Result<()> {
4675 anyhow::ensure!(
4676 self.execution_payload_marker().await?.is_some(),
4677 "Postgres execution requires protected payload storage"
4678 );
4679 self.validate_execution_payload_ready(keys).await
4680 }
4681
4682 pub(crate) async fn acquire_execution_payload_lease(
4684 &self,
4685 keys: &PayloadKeySet,
4686 ) -> anyhow::Result<ExecutionPayloadLease> {
4687 let mut transaction = self
4688 .pool
4689 .begin()
4690 .await
4691 .context("failed to start execution payload action lease")?;
4692 let marker = sqlx::query_scalar::<_, i16>(
4693 "SELECT version FROM execution_schema_version WHERE component = $1",
4694 )
4695 .bind(EXECUTION_PAYLOAD_COMPONENT)
4696 .fetch_optional(&mut *transaction)
4697 .await
4698 .context("failed to read execution payload marker")?;
4699
4700 let version = marker.ok_or_else(|| {
4701 anyhow::anyhow!("Postgres execution requires protected payload storage")
4702 })?;
4703 anyhow::ensure!(
4704 version == EXECUTION_PAYLOAD_PROTOCOL_VERSION,
4705 "Unsupported execution payload protection version {version}"
4706 );
4707 let row = sqlx::query(
4708 "SELECT deployment_id, protocol_version, operation, active_key_id \
4709 FROM execution_payload_state WHERE component = 'signed_transactions' \
4710 FOR SHARE",
4711 )
4712 .fetch_optional(&mut *transaction)
4713 .await
4714 .context("failed to lock execution payload state")?
4715 .ok_or_else(|| anyhow::anyhow!("Execution payload marker exists without state"))?;
4716 let state = execution_payload_state_from_row(&row)?;
4717 validate_execution_payload_state(&state, keys)?;
4718 anyhow::ensure!(
4719 state.operation == "ready",
4720 "Execution payload storage is in {} maintenance",
4721 state.operation
4722 );
4723
4724 Ok(ExecutionPayloadLease {
4725 _transaction: transaction,
4726 })
4727 }
4728
4729 pub(crate) async fn reserve_execution_payload_seal(
4731 &self,
4732 keys: &PayloadKeySet,
4733 ) -> anyhow::Result<()> {
4734 let mut transaction = self
4735 .pool
4736 .begin()
4737 .await
4738 .context("failed to start payload seal reservation")?;
4739 let row = sqlx::query(
4740 "SELECT deployment_id, protocol_version, operation, active_key_id \
4741 FROM execution_payload_state WHERE component = 'signed_transactions' FOR SHARE",
4742 )
4743 .fetch_optional(&mut *transaction)
4744 .await
4745 .context("failed to lock execution payload state")?
4746 .ok_or_else(|| anyhow::anyhow!("Execution payload protection is not active"))?;
4747 let state = execution_payload_state_from_row(&row)?;
4748 validate_execution_payload_state(&state, keys)?;
4749 anyhow::ensure!(
4750 state.operation == "ready",
4751 "Execution payload storage is in {} maintenance",
4752 state.operation
4753 );
4754 reserve_execution_payload_seal(&mut transaction, keys.active_key_id()).await?;
4755 transaction
4756 .commit()
4757 .await
4758 .context("failed to commit payload seal reservation")?;
4759 Ok(())
4760 }
4761
4762 async fn execution_payload_marker(&self) -> anyhow::Result<Option<i16>> {
4763 sqlx::query_scalar::<_, i16>(
4764 "SELECT version FROM execution_schema_version WHERE component = $1",
4765 )
4766 .bind(EXECUTION_PAYLOAD_COMPONENT)
4767 .fetch_optional(&self.pool)
4768 .await
4769 .context("failed to read execution payload marker")
4770 }
4771
4772 async fn execution_payload_state(&self) -> anyhow::Result<Option<ExecutionPayloadState>> {
4773 let row = sqlx::query(
4774 "SELECT deployment_id, protocol_version, operation, active_key_id \
4775 FROM execution_payload_state WHERE component = 'signed_transactions'",
4776 )
4777 .fetch_optional(&self.pool)
4778 .await
4779 .context("failed to read execution payload state")?;
4780 row.as_ref()
4781 .map(execution_payload_state_from_row)
4782 .transpose()
4783 }
4784
4785 async fn activate_execution_payload_storage(&self, keys: &PayloadKeySet) -> anyhow::Result<()> {
4786 let mut transaction = self
4787 .pool
4788 .begin()
4789 .await
4790 .context("failed to start execution payload activation")?;
4791 lock_execution_payload_operation(&mut transaction).await?;
4792 sqlx::query("LOCK TABLE execution_transaction_hash IN ACCESS EXCLUSIVE MODE")
4793 .execute(&mut *transaction)
4794 .await
4795 .context("failed to fence execution payload writers")?;
4796 let marker = sqlx::query_scalar::<_, i16>(
4797 "SELECT version FROM execution_schema_version WHERE component = $1",
4798 )
4799 .bind(EXECUTION_PAYLOAD_COMPONENT)
4800 .fetch_optional(&mut *transaction)
4801 .await
4802 .context("failed to recheck execution payload marker")?;
4803 if marker.is_some() {
4804 transaction
4805 .rollback()
4806 .await
4807 .context("failed to release concurrent payload activation")?;
4808 return Ok(());
4809 }
4810 let sealed_rows = sqlx::query_scalar::<_, i64>(
4811 "SELECT COUNT(*) FROM execution_transaction_hash WHERE sealed_transaction IS NOT NULL",
4812 )
4813 .fetch_one(&mut *transaction)
4814 .await
4815 .context("failed to inspect pre-activation envelopes")?;
4816 anyhow::ensure!(
4817 sealed_rows == 0,
4818 "Unprotected execution payload storage contains {sealed_rows} sealed row(s)"
4819 );
4820
4821 for statement in [
4822 "
4823 CREATE OR REPLACE FUNCTION execution_transaction_payload_fence()
4824 RETURNS TRIGGER AS $$
4825 DECLARE payload_operation TEXT;
4826 BEGIN
4827 IF NEW.raw_transaction IS NOT NULL
4828 AND (TG_OP = 'INSERT'
4829 OR OLD.raw_transaction IS NULL
4830 OR NEW.raw_transaction IS DISTINCT FROM OLD.raw_transaction) THEN
4831 SELECT operation INTO payload_operation
4832 FROM execution_payload_state
4833 WHERE component = 'signed_transactions';
4834 IF NOT (
4835 TG_OP = 'UPDATE'
4836 AND payload_operation = 'rollback'
4837 AND OLD.payload_expected
4838 AND OLD.raw_transaction IS NULL
4839 AND NEW.raw_transaction IS NOT NULL
4840 AND OLD.sealed_transaction IS NOT NULL
4841 AND NEW.sealed_transaction = OLD.sealed_transaction
4842 ) THEN
4843 RAISE EXCEPTION 'Plaintext signed transaction writes are disabled';
4844 END IF;
4845 END IF;
4846 RETURN NEW;
4847 END;
4848 $$ LANGUAGE plpgsql
4849 ",
4850 "DROP TRIGGER IF EXISTS execution_transaction_payload_fence ON execution_transaction_hash",
4851 "
4852 CREATE TRIGGER execution_transaction_payload_fence
4853 BEFORE INSERT OR UPDATE ON execution_transaction_hash
4854 FOR EACH ROW EXECUTE FUNCTION execution_transaction_payload_fence()
4855 ",
4856 ] {
4857 sqlx::query(statement)
4858 .execute(&mut *transaction)
4859 .await
4860 .context("failed to install execution payload write fence")?;
4861 }
4862 sqlx::query("INSERT INTO execution_schema_version (component, version) VALUES ($1, $2)")
4863 .bind(EXECUTION_PAYLOAD_COMPONENT)
4864 .bind(EXECUTION_PAYLOAD_PROTOCOL_VERSION)
4865 .execute(&mut *transaction)
4866 .await
4867 .context("failed to record execution payload marker")?;
4868 sqlx::query(
4869 "INSERT INTO execution_payload_state (component, deployment_id, protocol_version, operation, active_key_id) \
4870 VALUES ('signed_transactions', $1, $2, 'migrate', $3)",
4871 )
4872 .bind(keys.deployment_id())
4873 .bind(EXECUTION_PAYLOAD_PROTOCOL_VERSION)
4874 .bind(keys.active_key_id().as_slice())
4875 .execute(&mut *transaction)
4876 .await
4877 .context("failed to record execution payload migration state")?;
4878 sqlx::query(
4879 "INSERT INTO execution_payload_key_state (key_id, seals) VALUES ($1, 0) \
4880 ON CONFLICT (key_id) DO NOTHING",
4881 )
4882 .bind(keys.active_key_id().as_slice())
4883 .execute(&mut *transaction)
4884 .await
4885 .context("failed to initialize execution payload key state")?;
4886 transaction
4887 .commit()
4888 .await
4889 .context("failed to commit execution payload activation")?;
4890 Ok(())
4891 }
4892
4893 async fn migrate_execution_payload_batch(
4894 &self,
4895 keys: &PayloadKeySet,
4896 batch_size: i64,
4897 ) -> anyhow::Result<bool> {
4898 anyhow::ensure!(
4899 batch_size > 0,
4900 "Execution payload batch size must be positive"
4901 );
4902 let mut transaction = self
4903 .pool
4904 .begin()
4905 .await
4906 .context("failed to start execution payload migration batch")?;
4907 lock_execution_payload_operation(&mut transaction).await?;
4908 let state_row = sqlx::query(
4909 "SELECT deployment_id, protocol_version, operation, active_key_id \
4910 FROM execution_payload_state WHERE component = 'signed_transactions' FOR UPDATE",
4911 )
4912 .fetch_optional(&mut *transaction)
4913 .await
4914 .context("failed to lock execution payload migration state")?
4915 .ok_or_else(|| anyhow::anyhow!("Execution payload migration state is missing"))?;
4916 let state = execution_payload_state_from_row(&state_row)?;
4917 validate_execution_payload_state(&state, keys)?;
4918 anyhow::ensure!(
4919 state.operation == "migrate",
4920 "Execution payload storage is {}, not migrating",
4921 state.operation
4922 );
4923
4924 let rows = sqlx::query_as::<_, ExecutionTransactionHashRow>(
4925 "
4926 SELECT
4927 id, intent_id, chain_id, transaction_hash, payload_expected,
4928 raw_transaction, sealed_transaction, status, block_number, block_hash,
4929 receipt_success, gas_used, effective_gas_price, current
4930 FROM execution_transaction_hash
4931 WHERE payload_expected AND raw_transaction IS NOT NULL
4932 ORDER BY id
4933 LIMIT $1
4934 FOR UPDATE
4935 ",
4936 )
4937 .bind(batch_size)
4938 .fetch_all(&mut *transaction)
4939 .await
4940 .context("failed to load legacy signed transaction batch")?;
4941
4942 if rows.is_empty() {
4943 self.complete_execution_payload_migration(&mut transaction, keys)
4944 .await?;
4945 transaction
4946 .commit()
4947 .await
4948 .context("failed to commit execution payload migration completion")?;
4949 return Ok(true);
4950 }
4951
4952 for hash in rows {
4953 anyhow::ensure!(
4954 hash.sealed_transaction.is_none(),
4955 "Execution transaction {} contains both plaintext and sealed payloads",
4956 hash.id
4957 );
4958 let raw_transaction = hash
4959 .raw_transaction
4960 .as_deref()
4961 .expect("migration query requires plaintext");
4962 let intent = load_execution_intent(&mut transaction, hash.intent_id).await?;
4963 let context = authenticate_retained_payload(
4964 raw_transaction,
4965 &intent,
4966 &hash,
4967 keys.deployment_id(),
4968 )
4969 .with_context(|| format!("failed to authenticate execution payload {}", hash.id))?;
4970 reserve_execution_payload_seal(&mut transaction, keys.active_key_id()).await?;
4971 let envelope = keys.seal(raw_transaction, &context)?;
4972 let unsealed = keys.unseal(&envelope, &context)?;
4973 authenticate_retained_payload(&unsealed, &intent, &hash, keys.deployment_id())?;
4974 anyhow::ensure!(
4975 unsealed == raw_transaction,
4976 "Execution payload {} changed during seal round trip",
4977 hash.id
4978 );
4979 let result = sqlx::query(
4980 "UPDATE execution_transaction_hash \
4981 SET sealed_transaction = $2, raw_transaction = NULL, updated_at = NOW() \
4982 WHERE id = $1 AND raw_transaction = $3 AND sealed_transaction IS NULL",
4983 )
4984 .bind(hash.id)
4985 .bind(&envelope)
4986 .bind(raw_transaction)
4987 .execute(&mut *transaction)
4988 .await
4989 .context("failed to promote execution payload")?;
4990 anyhow::ensure!(
4991 result.rows_affected() == 1,
4992 "Execution payload {} changed during migration",
4993 hash.id
4994 );
4995 sqlx::query(
4996 "UPDATE execution_payload_state SET progress_id = $1, updated_at = NOW() \
4997 WHERE component = 'signed_transactions'",
4998 )
4999 .bind(hash.id)
5000 .execute(&mut *transaction)
5001 .await
5002 .context("failed to record execution payload migration progress")?;
5003 }
5004
5005 transaction
5006 .commit()
5007 .await
5008 .context("failed to commit execution payload migration batch")?;
5009 Ok(false)
5010 }
5011
5012 async fn complete_execution_payload_migration(
5013 &self,
5014 transaction: &mut Transaction<'_, Postgres>,
5015 keys: &PayloadKeySet,
5016 ) -> anyhow::Result<()> {
5017 sqlx::query("LOCK TABLE execution_transaction_hash IN SHARE ROW EXCLUSIVE MODE")
5018 .execute(&mut **transaction)
5019 .await
5020 .context("failed to lock execution payload migration completion")?;
5021 let invalid = sqlx::query_scalar::<_, i64>(
5022 "SELECT COUNT(*) FROM execution_transaction_hash \
5023 WHERE (payload_expected AND (raw_transaction IS NOT NULL OR sealed_transaction IS NULL)) \
5024 OR (NOT payload_expected AND (raw_transaction IS NOT NULL OR sealed_transaction IS NOT NULL))",
5025 )
5026 .fetch_one(&mut **transaction)
5027 .await
5028 .context("failed to verify migrated execution payload representations")?;
5029 anyhow::ensure!(
5030 invalid == 0,
5031 "Execution payload migration found {invalid} invalid representation(s)"
5032 );
5033 validate_execution_payload_key_inventory(transaction, keys).await?;
5034
5035 for statement in [
5036 "ALTER TABLE execution_transaction_hash \
5037 DROP CONSTRAINT IF EXISTS execution_transaction_payload_protected_check",
5038 "ALTER TABLE execution_transaction_hash \
5039 ADD CONSTRAINT execution_transaction_payload_protected_check CHECK ( \
5040 (payload_expected AND raw_transaction IS NULL AND sealed_transaction IS NOT NULL) \
5041 OR (NOT payload_expected AND raw_transaction IS NULL AND sealed_transaction IS NULL) \
5042 )",
5043 ] {
5044 sqlx::query(statement)
5045 .execute(&mut **transaction)
5046 .await
5047 .context("failed to install protected execution payload constraint")?;
5048 }
5049 sqlx::query(
5050 "UPDATE execution_payload_state SET operation = 'ready', updated_at = NOW() \
5051 WHERE component = 'signed_transactions' AND operation = 'migrate'",
5052 )
5053 .execute(&mut **transaction)
5054 .await
5055 .context("failed to mark execution payload storage ready")?;
5056 Ok(())
5057 }
5058
5059 async fn validate_execution_payload_ready(&self, keys: &PayloadKeySet) -> anyhow::Result<()> {
5060 let mut transaction = self
5061 .pool
5062 .begin()
5063 .await
5064 .context("failed to start execution payload validation")?;
5065 let state_row = sqlx::query(
5066 "SELECT deployment_id, protocol_version, operation, active_key_id \
5067 FROM execution_payload_state WHERE component = 'signed_transactions' FOR SHARE",
5068 )
5069 .fetch_optional(&mut *transaction)
5070 .await
5071 .context("failed to lock execution payload state")?
5072 .ok_or_else(|| anyhow::anyhow!("Execution payload state is missing"))?;
5073 let state = execution_payload_state_from_row(&state_row)?;
5074 validate_execution_payload_state(&state, keys)?;
5075 anyhow::ensure!(
5076 state.operation == "ready",
5077 "Execution payload storage is not ready"
5078 );
5079 let (payload_fence, payload_constraint) =
5080 sqlx::query_as::<_, (bool, bool)>(EXECUTION_PAYLOAD_INFRASTRUCTURE_QUERY)
5081 .fetch_one(&mut *transaction)
5082 .await
5083 .context("failed to inspect execution payload protection infrastructure")?;
5084 anyhow::ensure!(
5085 payload_fence && payload_constraint,
5086 "Execution payload storage is marked ready without its write fence or constraint"
5087 );
5088 validate_execution_payload_key_inventory(&mut transaction, keys).await?;
5089 transaction
5090 .commit()
5091 .await
5092 .context("failed to complete execution payload validation")?;
5093 Ok(())
5094 }
5095
5096 pub(crate) async fn check_execution_payload_storage(
5097 &self,
5098 keys: Option<&PayloadKeySet>,
5099 policy: Option<PayloadPolicy>,
5100 batch_size: i64,
5101 ) -> anyhow::Result<ExecutionPayloadCheck> {
5102 let (check, transaction) = self
5103 .inspect_execution_payload_storage(keys, policy, batch_size)
5104 .await?;
5105 transaction
5106 .commit()
5107 .await
5108 .context("failed to complete execution payload check")?;
5109 Ok(check)
5110 }
5111
5112 async fn inspect_execution_payload_storage(
5113 &self,
5114 keys: Option<&PayloadKeySet>,
5115 policy: Option<PayloadPolicy>,
5116 batch_size: i64,
5117 ) -> anyhow::Result<(ExecutionPayloadCheck, Transaction<'static, Postgres>)> {
5118 anyhow::ensure!(
5119 batch_size > 0,
5120 "Execution payload check batch size must be positive"
5121 );
5122 let mut transaction = self
5123 .pool
5124 .begin()
5125 .await
5126 .context("failed to start execution payload check")?;
5127 sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
5128 .execute(&mut *transaction)
5129 .await
5130 .context("failed to stabilize execution payload snapshot")?;
5131 sqlx::query("LOCK TABLE execution_transaction_hash IN SHARE MODE")
5132 .execute(&mut *transaction)
5133 .await
5134 .context("failed to stabilize execution payload check")?;
5135 let marker = sqlx::query_scalar::<_, i16>(
5136 "SELECT version FROM execution_schema_version WHERE component = $1",
5137 )
5138 .bind(EXECUTION_PAYLOAD_COMPONENT)
5139 .fetch_optional(&mut *transaction)
5140 .await
5141 .context("failed to read execution payload marker")?;
5142 let deployment_id = match (marker, keys) {
5143 (None, _) => {
5144 let state = sqlx::query_scalar::<_, bool>(
5145 "SELECT EXISTS (SELECT 1 FROM execution_payload_state) \
5146 OR EXISTS (SELECT 1 FROM execution_transaction_hash \
5147 WHERE sealed_transaction IS NOT NULL)",
5148 )
5149 .fetch_one(&mut *transaction)
5150 .await
5151 .context("failed to check legacy payload state")?;
5152 anyhow::ensure!(!state, "Legacy payload storage contains protected state");
5153 None
5154 }
5155 (Some(version), Some(keys)) => {
5156 anyhow::ensure!(
5157 version == EXECUTION_PAYLOAD_PROTOCOL_VERSION,
5158 "Unsupported execution payload protection version {version}"
5159 );
5160 let state_row = sqlx::query(
5161 "SELECT deployment_id, protocol_version, operation, active_key_id \
5162 FROM execution_payload_state WHERE component = 'signed_transactions' FOR SHARE",
5163 )
5164 .fetch_optional(&mut *transaction)
5165 .await
5166 .context("failed to lock execution payload state")?
5167 .ok_or_else(|| anyhow::anyhow!("Execution payload state is missing"))?;
5168 let state = execution_payload_state_from_row(&state_row)?;
5169 validate_execution_payload_state(&state, keys)?;
5170 anyhow::ensure!(
5171 state.operation == "ready",
5172 "Execution payload storage is not ready"
5173 );
5174 Some(state.deployment_id)
5175 }
5176 (Some(_), None) => anyhow::bail!(
5177 "Execution payload protection is active, but no payload key is configured"
5178 ),
5179 };
5180
5181 let mut cursor = 0_i64;
5182 let mut plaintext_rows = 0_u64;
5183 let mut original_rows = 0_u64;
5184 let mut replacement_rows = 0_u64;
5185 let mut authenticated_rows = 0_u64;
5186 let mut key_ids = BTreeSet::new();
5187
5188 loop {
5189 let rows = sqlx::query_as::<_, ExecutionTransactionHashRow>(
5190 "
5191 SELECT
5192 id, intent_id, chain_id, transaction_hash, payload_expected,
5193 raw_transaction, sealed_transaction, status, block_number, block_hash,
5194 receipt_success, gas_used, effective_gas_price, current
5195 FROM execution_transaction_hash
5196 WHERE id > $1
5197 ORDER BY id
5198 LIMIT $2
5199 ",
5200 )
5201 .bind(cursor)
5202 .bind(batch_size)
5203 .fetch_all(&mut *transaction)
5204 .await
5205 .context("failed to load execution payload check batch")?;
5206
5207 if rows.is_empty() {
5208 break;
5209 }
5210
5211 for hash in &rows {
5212 cursor = hash.id;
5213 plaintext_rows += u64::from(hash.raw_transaction.is_some());
5214 if !hash.payload_expected {
5215 replacement_rows += 1;
5216 anyhow::ensure!(
5217 hash.raw_transaction.is_none() && hash.sealed_transaction.is_none(),
5218 "Replacement execution transaction {} retains signed bytes",
5219 hash.id
5220 );
5221 continue;
5222 }
5223 original_rows += 1;
5224 let intent = load_execution_intent(&mut transaction, hash.intent_id).await?;
5225 let raw_transaction = if let (Some(keys), Some(deployment_id)) =
5226 (keys, deployment_id.as_deref())
5227 {
5228 anyhow::ensure!(
5229 hash.raw_transaction.is_none(),
5230 "Protected execution transaction {} contains plaintext",
5231 hash.id
5232 );
5233 let envelope = hash.sealed_transaction.as_deref().ok_or_else(|| {
5234 anyhow::anyhow!(
5235 "Protected execution transaction {} has no envelope",
5236 hash.id
5237 )
5238 })?;
5239 let key_id = envelope_key_id(envelope)?;
5240 anyhow::ensure!(
5241 keys.contains_key(&key_id),
5242 "Execution transaction {} requires an unavailable payload key",
5243 hash.id
5244 );
5245 key_ids.insert(alloy::hex::encode(key_id));
5246 let context = payload_context(&intent, hash, deployment_id)?;
5247 keys.unseal(envelope, &context)?
5248 } else {
5249 anyhow::ensure!(
5250 hash.sealed_transaction.is_none(),
5251 "Legacy execution transaction {} contains an envelope",
5252 hash.id
5253 );
5254 hash.raw_transaction.clone().ok_or_else(|| {
5255 anyhow::anyhow!("Legacy execution transaction {} has no plaintext", hash.id)
5256 })?
5257 };
5258 authenticate_retained_payload(
5259 &raw_transaction,
5260 &intent,
5261 hash,
5262 deployment_id.as_deref().unwrap_or(""),
5263 )?;
5264
5265 if let Some(policy) = policy
5266 && retained_payload_requires_policy(&intent, hash, policy)?
5267 {
5268 authenticate_payload(
5269 &raw_transaction,
5270 &intent,
5271 hash,
5272 policy,
5273 deployment_id.as_deref().unwrap_or(""),
5274 )
5275 .with_context(|| {
5276 format!(
5277 "execution intent {} transaction {} violates current execution policy",
5278 intent.id, hash.transaction_hash
5279 )
5280 })?;
5281 }
5282 authenticated_rows += 1;
5283 }
5284 }
5285 let read_roles = sqlx::query_scalar::<_, String>(
5286 "SELECT DISTINCT role_name FROM ( \
5287 SELECT tableowner AS role_name FROM pg_tables \
5288 WHERE schemaname = current_schema() AND tablename = 'execution_transaction_hash' \
5289 UNION \
5290 SELECT grantee AS role_name FROM information_schema.role_table_grants \
5291 WHERE table_schema = current_schema() \
5292 AND table_name = 'execution_transaction_hash' \
5293 AND privilege_type = 'SELECT' \
5294 ) AS roles ORDER BY role_name",
5295 )
5296 .fetch_all(&mut *transaction)
5297 .await
5298 .context("failed to inspect execution payload read roles")?;
5299 Ok((
5300 ExecutionPayloadCheck {
5301 protected: deployment_id.is_some(),
5302 deployment_id,
5303 plaintext_rows,
5304 original_rows,
5305 replacement_rows,
5306 authenticated_rows,
5307 key_ids: key_ids.into_iter().collect(),
5308 read_roles,
5309 },
5310 transaction,
5311 ))
5312 }
5313
5314 pub(crate) async fn rewrap_execution_payload_storage(
5315 &self,
5316 keys: &PayloadKeySet,
5317 batch_size: i64,
5318 ) -> anyhow::Result<()> {
5319 anyhow::ensure!(
5320 batch_size > 0,
5321 "Execution payload rewrap batch size must be positive"
5322 );
5323 self.begin_execution_payload_rewrap(keys).await?;
5324
5325 loop {
5326 if self
5327 .rewrap_execution_payload_batch(keys, batch_size)
5328 .await?
5329 {
5330 return Ok(());
5331 }
5332 }
5333 }
5334
5335 async fn begin_execution_payload_rewrap(&self, keys: &PayloadKeySet) -> anyhow::Result<()> {
5336 let mut transaction = self
5337 .pool
5338 .begin()
5339 .await
5340 .context("failed to start execution payload rewrap")?;
5341 lock_execution_payload_operation(&mut transaction).await?;
5342 let state_row = sqlx::query(
5343 "SELECT deployment_id, protocol_version, operation, active_key_id \
5344 FROM execution_payload_state WHERE component = 'signed_transactions' FOR UPDATE",
5345 )
5346 .fetch_optional(&mut *transaction)
5347 .await
5348 .context("failed to lock execution payload state for rewrap")?
5349 .ok_or_else(|| anyhow::anyhow!("Execution payload protection is not active"))?;
5350 let state = execution_payload_state_from_row(&state_row)?;
5351 anyhow::ensure!(
5352 state.protocol_version == EXECUTION_PAYLOAD_PROTOCOL_VERSION
5353 && state.deployment_id == keys.deployment_id(),
5354 "Execution payload rewrap context does not match this database"
5355 );
5356
5357 match state.operation.as_str() {
5358 "ready" => {
5359 let current_id: [u8; 32] = state
5360 .active_key_id
5361 .as_slice()
5362 .try_into()
5363 .context("database active payload key ID is invalid")?;
5364 anyhow::ensure!(
5365 keys.contains_key(¤t_id),
5366 "Current database payload key is not configured for rewrap"
5367 );
5368 validate_execution_payload_key_inventory(&mut transaction, keys).await?;
5369 if current_id == *keys.active_key_id() {
5370 transaction
5371 .commit()
5372 .await
5373 .context("failed to complete no-op execution payload rewrap")?;
5374 return Ok(());
5375 }
5376 sqlx::query(
5377 "UPDATE execution_payload_state \
5378 SET operation = 'rewrap', active_key_id = $1, progress_id = 0, updated_at = NOW() \
5379 WHERE component = 'signed_transactions'",
5380 )
5381 .bind(keys.active_key_id().as_slice())
5382 .execute(&mut *transaction)
5383 .await
5384 .context("failed to record execution payload rewrap target")?;
5385 }
5386 "rewrap" => validate_execution_payload_state(&state, keys)?,
5387 operation => anyhow::bail!(
5388 "Execution payload storage is in {operation} maintenance, not ready for rewrap"
5389 ),
5390 }
5391 sqlx::query(
5392 "INSERT INTO execution_payload_key_state (key_id, seals) VALUES ($1, 0) \
5393 ON CONFLICT (key_id) DO NOTHING",
5394 )
5395 .bind(keys.active_key_id().as_slice())
5396 .execute(&mut *transaction)
5397 .await
5398 .context("failed to initialize rewrap target key state")?;
5399 transaction
5400 .commit()
5401 .await
5402 .context("failed to commit execution payload rewrap state")?;
5403 Ok(())
5404 }
5405
5406 async fn rewrap_execution_payload_batch(
5407 &self,
5408 keys: &PayloadKeySet,
5409 batch_size: i64,
5410 ) -> anyhow::Result<bool> {
5411 let mut transaction = self
5412 .pool
5413 .begin()
5414 .await
5415 .context("failed to start execution payload rewrap batch")?;
5416 lock_execution_payload_operation(&mut transaction).await?;
5417 let state_row = sqlx::query(
5418 "SELECT deployment_id, protocol_version, operation, active_key_id \
5419 FROM execution_payload_state WHERE component = 'signed_transactions' FOR UPDATE",
5420 )
5421 .fetch_optional(&mut *transaction)
5422 .await
5423 .context("failed to lock execution payload rewrap state")?
5424 .ok_or_else(|| anyhow::anyhow!("Execution payload rewrap state is missing"))?;
5425 let state = execution_payload_state_from_row(&state_row)?;
5426 validate_execution_payload_state(&state, keys)?;
5427 if state.operation == "ready" {
5428 transaction
5429 .commit()
5430 .await
5431 .context("failed to complete execution payload rewrap")?;
5432 return Ok(true);
5433 }
5434 anyhow::ensure!(
5435 state.operation == "rewrap",
5436 "Execution payload storage is not rewrapping"
5437 );
5438 let rows = sqlx::query_as::<_, ExecutionTransactionHashRow>(
5439 "
5440 SELECT
5441 id, intent_id, chain_id, transaction_hash, payload_expected,
5442 raw_transaction, sealed_transaction, status, block_number, block_hash,
5443 receipt_success, gas_used, effective_gas_price, current
5444 FROM execution_transaction_hash
5445 WHERE payload_expected
5446 AND substring(sealed_transaction FROM 2 FOR 32) <> $1
5447 ORDER BY id
5448 LIMIT $2
5449 FOR UPDATE
5450 ",
5451 )
5452 .bind(keys.active_key_id().as_slice())
5453 .bind(batch_size)
5454 .fetch_all(&mut *transaction)
5455 .await
5456 .context("failed to load execution payload rewrap batch")?;
5457
5458 if rows.is_empty() {
5459 sqlx::query("LOCK TABLE execution_transaction_hash IN SHARE ROW EXCLUSIVE MODE")
5460 .execute(&mut *transaction)
5461 .await
5462 .context("failed to lock execution payload rewrap completion")?;
5463 let remaining = sqlx::query_scalar::<_, i64>(
5464 "SELECT COUNT(*) FROM execution_transaction_hash \
5465 WHERE payload_expected \
5466 AND (raw_transaction IS NOT NULL OR sealed_transaction IS NULL \
5467 OR substring(sealed_transaction FROM 2 FOR 32) <> $1)",
5468 )
5469 .bind(keys.active_key_id().as_slice())
5470 .fetch_one(&mut *transaction)
5471 .await
5472 .context("failed to verify execution payload rewrap completion")?;
5473 anyhow::ensure!(
5474 remaining == 0,
5475 "Execution payload rewrap left {remaining} row(s)"
5476 );
5477 sqlx::query(
5478 "UPDATE execution_payload_state SET operation = 'ready', updated_at = NOW() \
5479 WHERE component = 'signed_transactions' AND operation = 'rewrap'",
5480 )
5481 .execute(&mut *transaction)
5482 .await
5483 .context("failed to mark execution payload rewrap complete")?;
5484 transaction
5485 .commit()
5486 .await
5487 .context("failed to commit execution payload rewrap completion")?;
5488 return Ok(true);
5489 }
5490
5491 for hash in rows {
5492 let envelope = hash.sealed_transaction.as_deref().ok_or_else(|| {
5493 anyhow::anyhow!(
5494 "Execution payload {} has no envelope during rewrap",
5495 hash.id
5496 )
5497 })?;
5498 anyhow::ensure!(
5499 hash.raw_transaction.is_none(),
5500 "Execution payload {} contains plaintext during rewrap",
5501 hash.id
5502 );
5503 let intent = load_execution_intent(&mut transaction, hash.intent_id).await?;
5504 let context = payload_context(&intent, &hash, keys.deployment_id())?;
5505 let raw_transaction = keys.unseal(envelope, &context)?;
5506 authenticate_retained_payload(&raw_transaction, &intent, &hash, keys.deployment_id())?;
5507 reserve_execution_payload_seal(&mut transaction, keys.active_key_id()).await?;
5508 let rewrapped = keys.seal(&raw_transaction, &context)?;
5509 let verified = keys.unseal(&rewrapped, &context)?;
5510 authenticate_retained_payload(&verified, &intent, &hash, keys.deployment_id())?;
5511 anyhow::ensure!(
5512 verified == raw_transaction,
5513 "Execution payload {} changed during rewrap",
5514 hash.id
5515 );
5516 let result = sqlx::query(
5517 "UPDATE execution_transaction_hash SET sealed_transaction = $2, updated_at = NOW() \
5518 WHERE id = $1 AND sealed_transaction = $3 AND raw_transaction IS NULL",
5519 )
5520 .bind(hash.id)
5521 .bind(&rewrapped)
5522 .bind(envelope)
5523 .execute(&mut *transaction)
5524 .await
5525 .context("failed to persist rewrapped execution payload")?;
5526 anyhow::ensure!(
5527 result.rows_affected() == 1,
5528 "Execution payload {} changed during rewrap",
5529 hash.id
5530 );
5531 sqlx::query(
5532 "UPDATE execution_payload_state SET progress_id = $1, updated_at = NOW() \
5533 WHERE component = 'signed_transactions'",
5534 )
5535 .bind(hash.id)
5536 .execute(&mut *transaction)
5537 .await
5538 .context("failed to record execution payload rewrap progress")?;
5539 }
5540 transaction
5541 .commit()
5542 .await
5543 .context("failed to commit execution payload rewrap batch")?;
5544 Ok(false)
5545 }
5546
5547 pub(crate) async fn rollback_execution_payload_storage(
5548 &self,
5549 keys: &PayloadKeySet,
5550 batch_size: i64,
5551 ) -> anyhow::Result<()> {
5552 anyhow::ensure!(
5553 batch_size > 0,
5554 "Execution payload rollback batch size must be positive"
5555 );
5556 self.begin_execution_payload_rollback(keys).await?;
5557
5558 loop {
5559 if self
5560 .rollback_execution_payload_batch(keys, batch_size)
5561 .await?
5562 {
5563 return Ok(());
5564 }
5565 }
5566 }
5567
5568 async fn begin_execution_payload_rollback(&self, keys: &PayloadKeySet) -> anyhow::Result<()> {
5569 let mut transaction = self
5570 .pool
5571 .begin()
5572 .await
5573 .context("failed to start execution payload rollback")?;
5574 lock_execution_payload_operation(&mut transaction).await?;
5575 let state_row = sqlx::query(
5576 "SELECT deployment_id, protocol_version, operation, active_key_id \
5577 FROM execution_payload_state WHERE component = 'signed_transactions' FOR UPDATE",
5578 )
5579 .fetch_optional(&mut *transaction)
5580 .await
5581 .context("failed to lock execution payload state for rollback")?
5582 .ok_or_else(|| anyhow::anyhow!("Execution payload protection is not active"))?;
5583 let state = execution_payload_state_from_row(&state_row)?;
5584 validate_execution_payload_state(&state, keys)?;
5585 match state.operation.as_str() {
5586 "ready" => {
5587 sqlx::query(
5588 "ALTER TABLE execution_transaction_hash \
5589 DROP CONSTRAINT IF EXISTS execution_transaction_payload_protected_check",
5590 )
5591 .execute(&mut *transaction)
5592 .await
5593 .context("failed to open execution payload rollback representation")?;
5594 sqlx::query(
5595 "UPDATE execution_payload_state \
5596 SET operation = 'rollback', progress_id = 0, updated_at = NOW() \
5597 WHERE component = 'signed_transactions'",
5598 )
5599 .execute(&mut *transaction)
5600 .await
5601 .context("failed to record execution payload rollback state")?;
5602 }
5603 "rollback" => {}
5604 operation => anyhow::bail!(
5605 "Execution payload storage is in {operation} maintenance, not ready for rollback"
5606 ),
5607 }
5608 transaction
5609 .commit()
5610 .await
5611 .context("failed to commit execution payload rollback state")?;
5612 Ok(())
5613 }
5614
5615 async fn rollback_execution_payload_batch(
5616 &self,
5617 keys: &PayloadKeySet,
5618 batch_size: i64,
5619 ) -> anyhow::Result<bool> {
5620 let mut transaction = self
5621 .pool
5622 .begin()
5623 .await
5624 .context("failed to start execution payload rollback batch")?;
5625 lock_execution_payload_operation(&mut transaction).await?;
5626 let state_row = sqlx::query(
5627 "SELECT deployment_id, protocol_version, operation, active_key_id \
5628 FROM execution_payload_state WHERE component = 'signed_transactions' FOR UPDATE",
5629 )
5630 .fetch_optional(&mut *transaction)
5631 .await
5632 .context("failed to lock execution payload rollback state")?
5633 .ok_or_else(|| anyhow::anyhow!("Execution payload rollback state is missing"))?;
5634 let state = execution_payload_state_from_row(&state_row)?;
5635 validate_execution_payload_state(&state, keys)?;
5636 anyhow::ensure!(
5637 state.operation == "rollback",
5638 "Execution payload storage is not rolling back"
5639 );
5640 let rows = sqlx::query_as::<_, ExecutionTransactionHashRow>(
5641 "
5642 SELECT
5643 id, intent_id, chain_id, transaction_hash, payload_expected,
5644 raw_transaction, sealed_transaction, status, block_number, block_hash,
5645 receipt_success, gas_used, effective_gas_price, current
5646 FROM execution_transaction_hash
5647 WHERE payload_expected AND sealed_transaction IS NOT NULL
5648 ORDER BY id
5649 LIMIT $1
5650 FOR UPDATE
5651 ",
5652 )
5653 .bind(batch_size)
5654 .fetch_all(&mut *transaction)
5655 .await
5656 .context("failed to load execution payload rollback batch")?;
5657
5658 if rows.is_empty() {
5659 sqlx::query("LOCK TABLE execution_transaction_hash IN SHARE ROW EXCLUSIVE MODE")
5660 .execute(&mut *transaction)
5661 .await
5662 .context("failed to lock execution payload rollback completion")?;
5663 let invalid = sqlx::query_scalar::<_, i64>(
5664 "SELECT COUNT(*) FROM execution_transaction_hash \
5665 WHERE (payload_expected AND (raw_transaction IS NULL OR sealed_transaction IS NOT NULL)) \
5666 OR (NOT payload_expected AND (raw_transaction IS NOT NULL OR sealed_transaction IS NOT NULL))",
5667 )
5668 .fetch_one(&mut *transaction)
5669 .await
5670 .context("failed to verify execution payload rollback completion")?;
5671 anyhow::ensure!(
5672 invalid == 0,
5673 "Execution payload rollback left {invalid} invalid row(s)"
5674 );
5675
5676 for statement in [
5677 "DROP TRIGGER IF EXISTS execution_transaction_payload_fence ON execution_transaction_hash",
5678 "DELETE FROM execution_payload_state WHERE component = 'signed_transactions'",
5679 "DELETE FROM execution_schema_version WHERE component = 'evm_execution_payload'",
5680 ] {
5681 sqlx::query(statement)
5682 .execute(&mut *transaction)
5683 .await
5684 .context("failed to complete execution payload rollback")?;
5685 }
5686 transaction
5687 .commit()
5688 .await
5689 .context("failed to commit execution payload rollback completion")?;
5690 return Ok(true);
5691 }
5692
5693 for hash in rows {
5694 anyhow::ensure!(
5695 hash.raw_transaction.is_none(),
5696 "Execution payload {} contains both representations during rollback",
5697 hash.id
5698 );
5699 let envelope = hash
5700 .sealed_transaction
5701 .as_deref()
5702 .expect("rollback query requires envelope");
5703 let intent = load_execution_intent(&mut transaction, hash.intent_id).await?;
5704 let context = payload_context(&intent, &hash, keys.deployment_id())?;
5705 let raw_transaction = keys.unseal(envelope, &context)?;
5706 authenticate_retained_payload(&raw_transaction, &intent, &hash, keys.deployment_id())?;
5707 let result = sqlx::query(
5708 "UPDATE execution_transaction_hash SET raw_transaction = $2, updated_at = NOW() \
5709 WHERE id = $1 AND raw_transaction IS NULL AND sealed_transaction = $3",
5710 )
5711 .bind(hash.id)
5712 .bind(&raw_transaction)
5713 .bind(envelope)
5714 .execute(&mut *transaction)
5715 .await
5716 .context("failed to recreate plaintext execution payload")?;
5717 anyhow::ensure!(
5718 result.rows_affected() == 1,
5719 "Execution payload {} changed during rollback",
5720 hash.id
5721 );
5722 authenticate_retained_payload(&raw_transaction, &intent, &hash, keys.deployment_id())?;
5723 let result = sqlx::query(
5724 "UPDATE execution_transaction_hash SET sealed_transaction = NULL, updated_at = NOW() \
5725 WHERE id = $1 AND raw_transaction = $2 AND sealed_transaction = $3",
5726 )
5727 .bind(hash.id)
5728 .bind(&raw_transaction)
5729 .bind(envelope)
5730 .execute(&mut *transaction)
5731 .await
5732 .context("failed to clear rolled-back execution payload envelope")?;
5733 anyhow::ensure!(
5734 result.rows_affected() == 1,
5735 "Execution payload {} changed while clearing rollback envelope",
5736 hash.id
5737 );
5738 sqlx::query(
5739 "UPDATE execution_payload_state SET progress_id = $1, updated_at = NOW() \
5740 WHERE component = 'signed_transactions'",
5741 )
5742 .bind(hash.id)
5743 .execute(&mut *transaction)
5744 .await
5745 .context("failed to record execution payload rollback progress")?;
5746 }
5747 transaction
5748 .commit()
5749 .await
5750 .context("failed to commit execution payload rollback batch")?;
5751 Ok(false)
5752 }
5753
5754 pub async fn reserve_execution_intent(
5762 &self,
5763 intent: &ExecutionIntentInsert,
5764 ) -> anyhow::Result<ExecutionIntentRow> {
5765 let (transaction, row) = async {
5766 let chain_id = i32::try_from(intent.chain_id).with_context(|| {
5767 format!("Chain ID {} exceeds PostgreSQL INTEGER", intent.chain_id)
5768 })?;
5769 let created_block = i64::try_from(intent.created_block).with_context(|| {
5770 format!(
5771 "Execution creation block {} exceeds PostgreSQL BIGINT",
5772 intent.created_block
5773 )
5774 })?;
5775 let mut transaction = self
5776 .pool
5777 .begin()
5778 .await
5779 .context("failed to start execution intent reservation")?;
5780 Self::lock_execution_signer(&mut transaction, intent.chain_id, &intent.wallet_address)
5781 .await?;
5782 let row = sqlx::query_as::<_, ExecutionIntentRow>(
5783 "
5784 INSERT INTO execution_intent (
5785 schema_version, chain_id, wallet_address, purpose, status,
5786 client_order_id, trader_id, strategy_id, account_id, instrument_id,
5787 pool_address, transaction_to, transaction_input, transaction_value,
5788 amount_in, created_block
5789 )
5790 VALUES ($1, $2, $3, $4, 'prepared', $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
5791 RETURNING
5792 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
5793 client_order_id, trader_id, strategy_id, account_id, instrument_id,
5794 pool_address, transaction_to, transaction_input, transaction_value,
5795 amount_in, created_block, acknowledgement_emitted, fill_emitted,
5796 terminal_emitted, active
5797 ",
5798 )
5799 .bind(EXECUTION_SCHEMA_VERSION)
5800 .bind(chain_id)
5801 .bind(&intent.wallet_address)
5802 .bind(&intent.purpose)
5803 .bind(&intent.client_order_id)
5804 .bind(&intent.trader_id)
5805 .bind(&intent.strategy_id)
5806 .bind(&intent.account_id)
5807 .bind(&intent.instrument_id)
5808 .bind(&intent.pool_address)
5809 .bind(&intent.transaction_to)
5810 .bind(&intent.transaction_input)
5811 .bind(&intent.transaction_value)
5812 .bind(&intent.amount_in)
5813 .bind(created_block)
5814 .fetch_one(&mut *transaction)
5815 .await
5816 .context("failed to insert execution intent reservation")?;
5817
5818 sqlx::query(
5819 "
5820 INSERT INTO execution_transaction_transition (
5821 intent_id, transition_key, to_status, block_number
5822 ) VALUES ($1, 'prepared', 'prepared', $2)
5823 ",
5824 )
5825 .bind(row.id)
5826 .bind(created_block)
5827 .execute(&mut *transaction)
5828 .await
5829 .context("failed to record prepared execution intent")?;
5830
5831 Ok::<_, anyhow::Error>((transaction, row))
5832 }
5833 .await
5834 .map_err(|source| {
5835 anyhow::Error::new(ExecutionIntentReservationError {
5836 stage: ExecutionIntentReservationStage::BeforeCommit,
5837 source,
5838 })
5839 })?;
5840 transaction.commit().await.map_err(|e| {
5841 anyhow::Error::new(ExecutionIntentReservationError {
5842 stage: ExecutionIntentReservationStage::Commit,
5843 source: anyhow::Error::new(e)
5844 .context("failed to commit execution intent reservation"),
5845 })
5846 })?;
5847 Ok(row)
5848 }
5849
5850 pub async fn assign_execution_intent_nonce(
5859 &self,
5860 intent_id: i64,
5861 nonce: u64,
5862 ) -> anyhow::Result<()> {
5863 let nonce_db = i64::try_from(nonce)
5864 .with_context(|| format!("Execution nonce {nonce} exceeds PostgreSQL BIGINT"))?;
5865 let result = sqlx::query(
5866 "
5867 UPDATE execution_intent
5868 SET nonce = $2, updated_at = NOW()
5869 WHERE id = $1
5870 AND status = 'prepared'
5871 AND (nonce IS NULL OR nonce = $2)
5872 ",
5873 )
5874 .bind(intent_id)
5875 .bind(nonce_db)
5876 .execute(&self.pool)
5877 .await
5878 .map_err(|e| anyhow::anyhow!("Failed to assign nonce {nonce} to execution intent: {e}"))?;
5879 anyhow::ensure!(
5880 result.rows_affected() == 1,
5881 "Execution intent {intent_id} is not prepared for nonce {nonce}"
5882 );
5883 Ok(())
5884 }
5885
5886 pub(crate) async fn assign_execution_intent_nonce_verified(
5893 &self,
5894 assignment: &ExecutionNonceAssignment<'_>,
5895 ) -> anyhow::Result<()> {
5896 anyhow::ensure!(
5897 !assignment.decisions.is_empty(),
5898 "Verified nonce assignment requires decision evidence"
5899 );
5900 anyhow::ensure!(
5901 assignment.provider_ids.len() == 3 && assignment.operator_ids.len() == 3,
5902 "Verified nonce assignment requires exactly three provider and operator IDs"
5903 );
5904 anyhow::ensure!(
5905 assignment.failure_domain_ids.len() >= 3,
5906 "Verified nonce assignment requires the configured failure domains"
5907 );
5908 let chain_id = i32::try_from(assignment.chain_id)
5909 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
5910 let nonce =
5911 i64::try_from(assignment.nonce).context("Execution nonce exceeds PostgreSQL BIGINT")?;
5912 let mut transaction = self
5913 .pool
5914 .begin()
5915 .await
5916 .map_err(|e| anyhow::anyhow!("Failed to start verified nonce assignment: {e}"))?;
5917 let (manifest_version, manifest_digest, next_nonce, revision) =
5918 sqlx::query_as::<_, (String, String, i64, i64)>(
5919 "
5920 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
5921 FROM execution_verification_nonce
5922 WHERE chain_id = $1 AND wallet_address = $2
5923 FOR UPDATE
5924 ",
5925 )
5926 .bind(chain_id)
5927 .bind(assignment.wallet_address)
5928 .fetch_optional(&mut *transaction)
5929 .await
5930 .map_err(|e| anyhow::anyhow!("Failed to lock canonical nonce ledger: {e}"))?
5931 .ok_or_else(|| anyhow::anyhow!("Canonical nonce ledger is not initialized"))?;
5932 anyhow::ensure!(
5933 manifest_version == assignment.manifest_version
5934 && manifest_digest == assignment.manifest_digest,
5935 "Verified nonce assignment manifest identity changed"
5936 );
5937 anyhow::ensure!(
5938 next_nonce == nonce,
5939 "Execution nonce {} does not match canonical nonce {next_nonce}",
5940 assignment.nonce
5941 );
5942
5943 let (intent_chain_id, intent_wallet, intent_nonce, intent_status, intent_active) =
5944 sqlx::query_as::<_, (i32, String, Option<i64>, String, bool)>(
5945 "
5946 SELECT chain_id, wallet_address, nonce, status, active
5947 FROM execution_intent
5948 WHERE id = $1
5949 FOR UPDATE
5950 ",
5951 )
5952 .bind(assignment.intent_id)
5953 .fetch_optional(&mut *transaction)
5954 .await
5955 .map_err(|e| {
5956 anyhow::anyhow!("Failed to lock execution intent for nonce assignment: {e}")
5957 })?
5958 .ok_or_else(|| {
5959 anyhow::anyhow!("Execution intent {} was not found", assignment.intent_id)
5960 })?;
5961 anyhow::ensure!(
5962 intent_chain_id == chain_id
5963 && intent_wallet == assignment.wallet_address
5964 && intent_status == "prepared"
5965 && intent_active
5966 && intent_nonce.is_none_or(|assigned| assigned == nonce),
5967 "Execution intent {} cannot own canonical nonce {}",
5968 assignment.intent_id,
5969 assignment.nonce
5970 );
5971
5972 for (index, decision) in assignment.decisions.iter().enumerate() {
5973 let height_start = decision
5974 .height_start
5975 .map(i64::try_from)
5976 .transpose()
5977 .context("Verification height exceeds PostgreSQL BIGINT")?;
5978 let height_end = decision
5979 .height_end
5980 .map(i64::try_from)
5981 .transpose()
5982 .context("Verification height exceeds PostgreSQL BIGINT")?;
5983 let transition_key = format!(
5984 "pre_sign:{}:{}:{index}",
5985 assignment.intent_id, decision.read_class
5986 );
5987 sqlx::query(
5988 "
5989 INSERT INTO execution_verification_decision (
5990 intent_id, nonce, decision_class, read_class, height_start, height_end,
5991 manifest_version, manifest_digest, provider_ids, operator_ids,
5992 failure_domain_ids, response_class, normalized_value_digest,
5993 nonce_revision, outcome, transition_key
5994 )
5995 VALUES (
5996 $1, $2, 'pre_sign', $3, $4, $5, $6, $7, $8, $9, $10,
5997 'all_valid', $11, $12, 'verified', $13
5998 )
5999 ",
6000 )
6001 .bind(assignment.intent_id)
6002 .bind(nonce)
6003 .bind(decision.read_class)
6004 .bind(height_start)
6005 .bind(height_end)
6006 .bind(assignment.manifest_version)
6007 .bind(assignment.manifest_digest)
6008 .bind(assignment.provider_ids)
6009 .bind(assignment.operator_ids)
6010 .bind(assignment.failure_domain_ids)
6011 .bind(&decision.normalized_value_digest)
6012 .bind(revision)
6013 .bind(transition_key)
6014 .execute(&mut *transaction)
6015 .await
6016 .map_err(|e| anyhow::anyhow!("Failed to persist pre-sign verification: {e}"))?;
6017 }
6018
6019 let result = sqlx::query(
6020 "
6021 UPDATE execution_intent
6022 SET nonce = $2, updated_at = NOW()
6023 WHERE id = $1
6024 AND status = 'prepared'
6025 AND active
6026 AND (nonce IS NULL OR nonce = $2)
6027 ",
6028 )
6029 .bind(assignment.intent_id)
6030 .bind(nonce)
6031 .execute(&mut *transaction)
6032 .await
6033 .map_err(|e| anyhow::anyhow!("Failed to assign verified execution nonce: {e}"))?;
6034 anyhow::ensure!(
6035 result.rows_affected() == 1,
6036 "Execution intent {} is not prepared for canonical nonce {}",
6037 assignment.intent_id,
6038 assignment.nonce
6039 );
6040 transaction
6041 .commit()
6042 .await
6043 .map_err(|e| anyhow::anyhow!("Failed to commit verified nonce assignment: {e}"))?;
6044 Ok(())
6045 }
6046
6047 pub(crate) async fn record_execution_verification_batch(
6049 &self,
6050 batch: &ExecutionVerificationBatch<'_>,
6051 ) -> anyhow::Result<()> {
6052 anyhow::ensure!(
6053 !batch.decision_class.trim().is_empty() && !batch.decisions.is_empty(),
6054 "Verified action requires a decision class and evidence"
6055 );
6056 anyhow::ensure!(
6057 batch.provider_ids.len() == 3
6058 && batch.operator_ids.len() == 3
6059 && batch.failure_domain_ids.len() >= 3,
6060 "Verified action requires the configured provider identities"
6061 );
6062 let chain_id = i32::try_from(batch.chain_id)
6063 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
6064 let nonce =
6065 i64::try_from(batch.nonce).context("Execution nonce exceeds PostgreSQL BIGINT")?;
6066 let mut transaction = self
6067 .pool
6068 .begin()
6069 .await
6070 .map_err(|e| anyhow::anyhow!("Failed to start verified action evidence: {e}"))?;
6071 let (manifest_version, manifest_digest, revision) =
6072 sqlx::query_as::<_, (String, String, i64)>(
6073 "
6074 SELECT manifest_version, manifest_digest, revision
6075 FROM execution_verification_nonce
6076 WHERE chain_id = $1 AND wallet_address = $2
6077 FOR SHARE
6078 ",
6079 )
6080 .bind(chain_id)
6081 .bind(batch.wallet_address)
6082 .fetch_optional(&mut *transaction)
6083 .await
6084 .map_err(|e| anyhow::anyhow!("Failed to read verified action nonce ledger: {e}"))?
6085 .ok_or_else(|| anyhow::anyhow!("Canonical nonce ledger is not initialized"))?;
6086 anyhow::ensure!(
6087 manifest_version == batch.manifest_version && manifest_digest == batch.manifest_digest,
6088 "Verified action manifest identity changed"
6089 );
6090 let intent_nonce = sqlx::query_scalar::<_, Option<i64>>(
6091 "
6092 SELECT nonce
6093 FROM execution_intent
6094 WHERE id = $1 AND chain_id = $2 AND wallet_address = $3 AND active
6095 FOR UPDATE
6096 ",
6097 )
6098 .bind(batch.intent_id)
6099 .bind(chain_id)
6100 .bind(batch.wallet_address)
6101 .fetch_optional(&mut *transaction)
6102 .await
6103 .map_err(|e| anyhow::anyhow!("Failed to lock intent for verified action: {e}"))?
6104 .ok_or_else(|| anyhow::anyhow!("Active verified-action intent was not found"))?;
6105 anyhow::ensure!(
6106 intent_nonce == Some(nonce),
6107 "Verified action nonce does not match the active intent"
6108 );
6109 let attempt = sqlx::query_scalar::<_, i64>(
6110 "
6111 SELECT COUNT(*)
6112 FROM execution_verification_decision
6113 WHERE intent_id = $1 AND decision_class = $2
6114 ",
6115 )
6116 .bind(batch.intent_id)
6117 .bind(batch.decision_class)
6118 .fetch_one(&mut *transaction)
6119 .await
6120 .map_err(|e| anyhow::anyhow!("Failed to number verified action evidence: {e}"))?;
6121
6122 for (index, decision) in batch.decisions.iter().enumerate() {
6123 let height_start = decision
6124 .height_start
6125 .map(i64::try_from)
6126 .transpose()
6127 .context("Verification height exceeds PostgreSQL BIGINT")?;
6128 let height_end = decision
6129 .height_end
6130 .map(i64::try_from)
6131 .transpose()
6132 .context("Verification height exceeds PostgreSQL BIGINT")?;
6133 let transition_key = format!(
6134 "{}:{}:{attempt}:{}:{index}",
6135 batch.decision_class, batch.intent_id, decision.read_class
6136 );
6137 sqlx::query(
6138 "
6139 INSERT INTO execution_verification_decision (
6140 intent_id, nonce, decision_class, read_class, height_start, height_end,
6141 manifest_version, manifest_digest, provider_ids, operator_ids,
6142 failure_domain_ids, response_class, normalized_value_digest,
6143 nonce_revision, outcome, transition_key
6144 )
6145 VALUES (
6146 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
6147 'all_valid', $12, $13, 'verified', $14
6148 )
6149 ",
6150 )
6151 .bind(batch.intent_id)
6152 .bind(nonce)
6153 .bind(batch.decision_class)
6154 .bind(decision.read_class)
6155 .bind(height_start)
6156 .bind(height_end)
6157 .bind(batch.manifest_version)
6158 .bind(batch.manifest_digest)
6159 .bind(batch.provider_ids)
6160 .bind(batch.operator_ids)
6161 .bind(batch.failure_domain_ids)
6162 .bind(&decision.normalized_value_digest)
6163 .bind(revision)
6164 .bind(transition_key)
6165 .execute(&mut *transaction)
6166 .await
6167 .map_err(|e| anyhow::anyhow!("Failed to persist verified action evidence: {e}"))?;
6168 }
6169 transaction
6170 .commit()
6171 .await
6172 .map_err(|e| anyhow::anyhow!("Failed to commit verified action evidence: {e}"))?;
6173 Ok(())
6174 }
6175
6176 pub(crate) async fn load_execution_replacement_cursor(
6177 &self,
6178 intent_id: i64,
6179 chain_id: u32,
6180 wallet_address: &str,
6181 nonce: u64,
6182 manifest_digest: &str,
6183 ) -> anyhow::Result<Option<ExecutionVerifiedHeader>> {
6184 let chain_id = i32::try_from(chain_id)
6185 .context("Replacement scan chain ID exceeds PostgreSQL INTEGER")?;
6186 let nonce =
6187 i64::try_from(nonce).context("Replacement scan nonce exceeds PostgreSQL BIGINT")?;
6188 let row = sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
6189 "
6190 SELECT
6191 h.number, h.hash, h.parent_hash, h.timestamp, h.base_fee_per_gas,
6192 s.manifest_digest
6193 FROM execution_replacement_scan AS s
6194 JOIN execution_verified_finalized_header AS h
6195 ON h.chain_id = s.chain_id
6196 AND h.wallet_address = s.wallet_address
6197 AND h.number = s.finalized_cursor_number
6198 AND h.hash = s.finalized_cursor_hash
6199 WHERE s.intent_id = $1
6200 AND s.chain_id = $2
6201 AND s.wallet_address = $3
6202 AND s.nonce = $4
6203 ",
6204 )
6205 .bind(intent_id)
6206 .bind(chain_id)
6207 .bind(wallet_address)
6208 .bind(nonce)
6209 .fetch_optional(&self.pool)
6210 .await
6211 .context("failed to load replacement scan cursor")?;
6212 row.map(
6213 |(number, hash, parent_hash, timestamp, base_fee, stored_digest)| {
6214 anyhow::ensure!(
6215 stored_digest == manifest_digest,
6216 "Replacement scan manifest identity changed"
6217 );
6218 Ok(ExecutionVerifiedHeader {
6219 number: u64::try_from(number)
6220 .context("Replacement scan cursor number is negative")?,
6221 hash,
6222 parent_hash,
6223 timestamp: u64::try_from(timestamp)
6224 .context("Replacement scan cursor timestamp is negative")?,
6225 base_fee_per_gas: base_fee
6226 .map(|value| {
6227 value.parse::<u128>().map_err(|_| {
6228 anyhow::anyhow!("Replacement scan cursor base fee is invalid")
6229 })
6230 })
6231 .transpose()?,
6232 })
6233 },
6234 )
6235 .transpose()
6236 }
6237
6238 pub(crate) async fn record_execution_replacement_scan(
6239 &self,
6240 scan: &ExecutionReplacementScan<'_>,
6241 ) -> anyhow::Result<()> {
6242 anyhow::ensure!(
6243 !scan.decisions.is_empty()
6244 && scan.provider_ids.len() == 3
6245 && scan.operator_ids.len() == 3
6246 && scan.failure_domain_ids.len() >= 3,
6247 "Replacement scan verification evidence is incomplete"
6248 );
6249 let chain_id = i32::try_from(scan.chain_id)
6250 .context("Replacement scan chain ID exceeds PostgreSQL INTEGER")?;
6251 let nonce = i64::try_from(scan.nonce)
6252 .context("Replacement scan nonce exceeds PostgreSQL BIGINT")?;
6253 let mut transaction = self
6254 .pool
6255 .begin()
6256 .await
6257 .context("failed to start verified replacement scan transition")?;
6258 let (stored_version, stored_digest, stored_nonce, revision) =
6259 sqlx::query_as::<_, (String, String, i64, i64)>(
6260 "
6261 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
6262 FROM execution_verification_nonce
6263 WHERE chain_id = $1 AND wallet_address = $2
6264 FOR SHARE
6265 ",
6266 )
6267 .bind(chain_id)
6268 .bind(scan.wallet_address)
6269 .fetch_optional(&mut *transaction)
6270 .await
6271 .context("failed to read replacement scan nonce ledger")?
6272 .ok_or_else(|| anyhow::anyhow!("Canonical nonce ledger is not initialized"))?;
6273 anyhow::ensure!(
6274 stored_version == scan.manifest_version
6275 && stored_digest == scan.manifest_digest
6276 && stored_nonce == nonce,
6277 "Replacement scan conflicts with the canonical nonce or manifest ledger"
6278 );
6279 let current_status = sqlx::query_scalar::<_, String>(
6280 "
6281 SELECT status
6282 FROM execution_intent
6283 WHERE id = $1 AND chain_id = $2 AND wallet_address = $3
6284 AND nonce = $4 AND active
6285 FOR UPDATE
6286 ",
6287 )
6288 .bind(scan.intent_id)
6289 .bind(chain_id)
6290 .bind(scan.wallet_address)
6291 .bind(nonce)
6292 .fetch_optional(&mut *transaction)
6293 .await
6294 .context("failed to lock the replacement scan intent")?
6295 .ok_or_else(|| anyhow::anyhow!("Active replacement scan intent was not found"))?;
6296
6297 if let Some(cursor) = scan.finalized_cursor {
6298 let number = i64::try_from(cursor.number)
6299 .context("Replacement scan cursor exceeds PostgreSQL BIGINT")?;
6300 let durable_hash = sqlx::query_scalar::<_, String>(
6301 "
6302 SELECT hash
6303 FROM execution_verified_finalized_header
6304 WHERE chain_id = $1 AND wallet_address = $2 AND number = $3
6305 ",
6306 )
6307 .bind(chain_id)
6308 .bind(scan.wallet_address)
6309 .bind(number)
6310 .fetch_optional(&mut *transaction)
6311 .await
6312 .context("failed to validate replacement cursor against finalized headers")?
6313 .ok_or_else(|| {
6314 anyhow::anyhow!(
6315 "Replacement scan cursor {} is not durably finalized",
6316 cursor.number
6317 )
6318 })?;
6319 anyhow::ensure!(
6320 durable_hash == cursor.hash,
6321 "Replacement scan cursor conflicts with the finalized header ledger"
6322 );
6323 let existing = sqlx::query_as::<_, (i64, String)>(
6324 "
6325 SELECT finalized_cursor_number, finalized_cursor_hash
6326 FROM execution_replacement_scan
6327 WHERE intent_id = $1
6328 FOR UPDATE
6329 ",
6330 )
6331 .bind(scan.intent_id)
6332 .fetch_optional(&mut *transaction)
6333 .await
6334 .context("failed to lock replacement scan progress")?;
6335
6336 if let Some((stored_number, stored_hash)) = existing {
6337 anyhow::ensure!(
6338 number > stored_number
6339 || (number == stored_number && cursor.hash == stored_hash),
6340 "Replacement scan cursor regressed or changed"
6341 );
6342 }
6343 sqlx::query(
6344 "
6345 INSERT INTO execution_replacement_scan (
6346 intent_id, chain_id, wallet_address, nonce,
6347 finalized_cursor_number, finalized_cursor_hash, manifest_digest
6348 ) VALUES ($1, $2, $3, $4, $5, $6, $7)
6349 ON CONFLICT (intent_id) DO UPDATE SET
6350 finalized_cursor_number = EXCLUDED.finalized_cursor_number,
6351 finalized_cursor_hash = EXCLUDED.finalized_cursor_hash,
6352 updated_at = NOW()
6353 ",
6354 )
6355 .bind(scan.intent_id)
6356 .bind(chain_id)
6357 .bind(scan.wallet_address)
6358 .bind(nonce)
6359 .bind(number)
6360 .bind(&cursor.hash)
6361 .bind(scan.manifest_digest)
6362 .execute(&mut *transaction)
6363 .await
6364 .context("failed to persist replacement scan cursor")?;
6365 }
6366
6367 let attempt = sqlx::query_scalar::<_, i64>(
6368 "
6369 SELECT COUNT(*)
6370 FROM execution_verification_decision
6371 WHERE intent_id = $1 AND decision_class = 'replacement_scan'
6372 ",
6373 )
6374 .bind(scan.intent_id)
6375 .fetch_one(&mut *transaction)
6376 .await
6377 .context("failed to number replacement scan evidence")?;
6378 for (index, decision) in scan.decisions.iter().enumerate() {
6379 let height_start = decision
6380 .height_start
6381 .map(i64::try_from)
6382 .transpose()
6383 .context("Replacement scan height exceeds PostgreSQL BIGINT")?;
6384 let height_end = decision
6385 .height_end
6386 .map(i64::try_from)
6387 .transpose()
6388 .context("Replacement scan height exceeds PostgreSQL BIGINT")?;
6389 let transition_key = format!(
6390 "replacement_scan:{}:{attempt}:{}:{index}",
6391 scan.intent_id, decision.read_class
6392 );
6393 sqlx::query(
6394 "
6395 INSERT INTO execution_verification_decision (
6396 intent_id, nonce, decision_class, read_class, height_start, height_end,
6397 manifest_version, manifest_digest, provider_ids, operator_ids,
6398 failure_domain_ids, response_class, normalized_value_digest,
6399 nonce_revision, outcome, transition_key
6400 ) VALUES (
6401 $1, $2, 'replacement_scan', $3, $4, $5, $6, $7, $8, $9, $10,
6402 'all_valid', $11, $12, 'verified', $13
6403 )
6404 ",
6405 )
6406 .bind(scan.intent_id)
6407 .bind(nonce)
6408 .bind(decision.read_class)
6409 .bind(height_start)
6410 .bind(height_end)
6411 .bind(scan.manifest_version)
6412 .bind(scan.manifest_digest)
6413 .bind(scan.provider_ids)
6414 .bind(scan.operator_ids)
6415 .bind(scan.failure_domain_ids)
6416 .bind(&decision.normalized_value_digest)
6417 .bind(revision)
6418 .bind(transition_key)
6419 .execute(&mut *transaction)
6420 .await
6421 .context("failed to persist replacement scan evidence")?;
6422 }
6423
6424 if let Some(transaction_hash) = scan.matched_transaction_hash {
6425 anyhow::ensure!(
6426 execution_transition_allowed(¤t_status, TransactionStatus::Replaced),
6427 "Intent cannot attach a verified replacement from status {current_status}"
6428 );
6429 let (hash_id, payload_expected, already_current) =
6430 sqlx::query_as::<_, (i64, bool, bool)>(
6431 "
6432 SELECT id, payload_expected, current
6433 FROM execution_transaction_hash
6434 WHERE intent_id = $1 AND chain_id = $2 AND transaction_hash = $3
6435 FOR UPDATE
6436 ",
6437 )
6438 .bind(scan.intent_id)
6439 .bind(chain_id)
6440 .bind(transaction_hash)
6441 .fetch_optional(&mut *transaction)
6442 .await
6443 .context("failed to lock authenticated replacement payload")?
6444 .ok_or_else(|| {
6445 anyhow::anyhow!(
6446 "Replacement transaction {transaction_hash} has no retained payload"
6447 )
6448 })?;
6449 anyhow::ensure!(
6450 payload_expected,
6451 "Replacement transaction {transaction_hash} is not authenticated"
6452 );
6453
6454 if !already_current {
6455 sqlx::query(
6456 "
6457 UPDATE execution_transaction_hash
6458 SET current = FALSE, status = 'replaced', updated_at = NOW()
6459 WHERE intent_id = $1 AND current
6460 ",
6461 )
6462 .bind(scan.intent_id)
6463 .execute(&mut *transaction)
6464 .await
6465 .context("failed to retire the prior transaction hash")?;
6466 sqlx::query(
6467 "
6468 UPDATE execution_transaction_hash
6469 SET current = TRUE, status = 'replaced', updated_at = NOW()
6470 WHERE id = $1
6471 ",
6472 )
6473 .bind(hash_id)
6474 .execute(&mut *transaction)
6475 .await
6476 .context("failed to attach the authenticated replacement hash")?;
6477 sqlx::query(
6478 "UPDATE execution_intent SET status = 'replaced', updated_at = NOW() WHERE id = $1",
6479 )
6480 .bind(scan.intent_id)
6481 .execute(&mut *transaction)
6482 .await
6483 .context("failed to mark the execution intent replaced")?;
6484 sqlx::query(
6485 "
6486 INSERT INTO execution_transaction_transition (
6487 intent_id, transaction_hash_id, transition_key, from_status, to_status
6488 ) VALUES ($1, $2, $3, $4, 'replaced')
6489 ON CONFLICT (intent_id, transition_key) DO NOTHING
6490 ",
6491 )
6492 .bind(scan.intent_id)
6493 .bind(hash_id)
6494 .bind(format!("replacement_verified:{transaction_hash}"))
6495 .bind(current_status)
6496 .execute(&mut *transaction)
6497 .await
6498 .context("failed to record the verified replacement transition")?;
6499 }
6500 }
6501
6502 transaction
6503 .commit()
6504 .await
6505 .context("failed to commit verified replacement scan")?;
6506 Ok(())
6507 }
6508
6509 pub async fn mark_execution_intent_recoverable(&self, intent_id: i64) -> anyhow::Result<()> {
6515 let mut transaction = self.pool.begin().await.map_err(|e| {
6516 anyhow::anyhow!("Failed to start recoverable execution transition: {e}")
6517 })?;
6518 let current_status = sqlx::query_scalar::<_, String>(
6519 "SELECT status FROM execution_intent WHERE id = $1 FOR UPDATE",
6520 )
6521 .bind(intent_id)
6522 .fetch_optional(&mut *transaction)
6523 .await
6524 .map_err(|e| anyhow::anyhow!("Failed to lock recoverable execution intent: {e}"))?
6525 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))?;
6526 anyhow::ensure!(
6527 current_status == "prepared",
6528 "Execution intent {intent_id} is {current_status}, not recoverable before signing"
6529 );
6530 let result = sqlx::query(
6531 "
6532 UPDATE execution_intent
6533 SET status = 'recoverable', active = FALSE, updated_at = NOW()
6534 WHERE id = $1 AND status = 'prepared'
6535 ",
6536 )
6537 .bind(intent_id)
6538 .execute(&mut *transaction)
6539 .await
6540 .map_err(|e| anyhow::anyhow!("Failed to mark execution intent recoverable: {e}"))?;
6541 anyhow::ensure!(
6542 result.rows_affected() == 1,
6543 "Execution intent {intent_id} is not recoverable from preparation"
6544 );
6545 sqlx::query(
6546 "
6547 INSERT INTO execution_transaction_transition (
6548 intent_id, transition_key, from_status, to_status
6549 ) VALUES ($1, 'recoverable', $2, 'recoverable')
6550 ON CONFLICT (intent_id, transition_key) DO NOTHING
6551 ",
6552 )
6553 .bind(intent_id)
6554 .bind(current_status)
6555 .execute(&mut *transaction)
6556 .await
6557 .map_err(|e| anyhow::anyhow!("Failed to record recoverable transition: {e}"))?;
6558 transaction
6559 .commit()
6560 .await
6561 .map_err(|e| anyhow::anyhow!("Failed to commit recoverable transition: {e}"))?;
6562 Ok(())
6563 }
6564
6565 pub async fn add_execution_transaction_hash(
6572 &self,
6573 intent_id: i64,
6574 chain_id: u32,
6575 transaction_hash: &str,
6576 raw_transaction: &[u8],
6577 ) -> anyhow::Result<ExecutionTransactionHashRow> {
6578 self.add_execution_transaction_payload(
6579 intent_id,
6580 chain_id,
6581 transaction_hash,
6582 Some(raw_transaction),
6583 None,
6584 )
6585 .await
6586 }
6587
6588 pub(crate) async fn add_execution_transaction_envelope(
6589 &self,
6590 intent_id: i64,
6591 chain_id: u32,
6592 transaction_hash: &str,
6593 sealed_transaction: &[u8],
6594 ) -> anyhow::Result<ExecutionTransactionHashRow> {
6595 self.add_execution_transaction_payload(
6596 intent_id,
6597 chain_id,
6598 transaction_hash,
6599 None,
6600 Some(sealed_transaction),
6601 )
6602 .await
6603 }
6604
6605 async fn add_execution_transaction_payload(
6606 &self,
6607 intent_id: i64,
6608 chain_id: u32,
6609 transaction_hash: &str,
6610 raw_transaction: Option<&[u8]>,
6611 sealed_transaction: Option<&[u8]>,
6612 ) -> anyhow::Result<ExecutionTransactionHashRow> {
6613 anyhow::ensure!(
6614 raw_transaction.is_some() != sealed_transaction.is_some(),
6615 "A signed transaction requires exactly one payload representation"
6616 );
6617 let chain_id_db = i32::try_from(chain_id)
6618 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
6619 let mut transaction =
6620 self.pool.begin().await.map_err(|e| {
6621 anyhow::anyhow!("Failed to start signed transaction persistence: {e}")
6622 })?;
6623
6624 if let Some(envelope) = sealed_transaction {
6625 let state_row = sqlx::query(
6626 "SELECT deployment_id, protocol_version, operation, active_key_id \
6627 FROM execution_payload_state WHERE component = 'signed_transactions' FOR SHARE",
6628 )
6629 .fetch_optional(&mut *transaction)
6630 .await
6631 .context("failed to lock execution payload state for protected persistence")?
6632 .ok_or_else(|| anyhow::anyhow!("Execution payload protection is not active"))?;
6633 let state = execution_payload_state_from_row(&state_row)?;
6634 anyhow::ensure!(
6635 state.protocol_version == EXECUTION_PAYLOAD_PROTOCOL_VERSION
6636 && state.operation == "ready",
6637 "Execution payload storage is not ready for protected persistence"
6638 );
6639 anyhow::ensure!(
6640 envelope_key_id(envelope)?.as_slice() == state.active_key_id.as_slice(),
6641 "Signed transaction envelope does not use the database active key"
6642 );
6643 } else {
6644 let marker = sqlx::query_scalar::<_, bool>(
6645 "SELECT EXISTS (SELECT 1 FROM execution_schema_version WHERE component = $1)",
6646 )
6647 .bind(EXECUTION_PAYLOAD_COMPONENT)
6648 .fetch_one(&mut *transaction)
6649 .await
6650 .context("failed to inspect execution payload marker")?;
6651 anyhow::ensure!(
6652 !marker,
6653 "Plaintext signed transaction persistence is disabled after payload activation"
6654 );
6655 }
6656 let current_status = sqlx::query_scalar::<_, String>(
6657 "SELECT status FROM execution_intent WHERE id = $1 FOR UPDATE",
6658 )
6659 .bind(intent_id)
6660 .fetch_optional(&mut *transaction)
6661 .await
6662 .map_err(|e| anyhow::anyhow!("Failed to lock execution intent {intent_id}: {e}"))?
6663 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))?;
6664 anyhow::ensure!(
6665 current_status == TransactionStatus::Prepared.as_str()
6666 || current_status == TransactionStatus::Signed.as_str(),
6667 "Execution intent {intent_id} is {current_status}, not prepared for signing"
6668 );
6669
6670 let row = sqlx::query_as::<_, ExecutionTransactionHashRow>(
6671 "
6672 INSERT INTO execution_transaction_hash (
6673 intent_id, chain_id, transaction_hash, payload_expected,
6674 raw_transaction, sealed_transaction, status
6675 )
6676 SELECT id, chain_id, $3, TRUE, $4, $5, 'signed'
6677 FROM execution_intent
6678 WHERE id = $1 AND chain_id = $2 AND nonce IS NOT NULL
6679 ON CONFLICT (chain_id, transaction_hash) DO UPDATE
6680 SET transaction_hash = EXCLUDED.transaction_hash
6681 WHERE execution_transaction_hash.intent_id = EXCLUDED.intent_id
6682 AND execution_transaction_hash.payload_expected
6683 AND (
6684 (
6685 EXCLUDED.raw_transaction IS NOT NULL
6686 AND execution_transaction_hash.raw_transaction = EXCLUDED.raw_transaction
6687 AND execution_transaction_hash.sealed_transaction IS NULL
6688 )
6689 OR (
6690 EXCLUDED.sealed_transaction IS NOT NULL
6691 AND execution_transaction_hash.raw_transaction IS NULL
6692 AND execution_transaction_hash.sealed_transaction IS NOT NULL
6693 )
6694 )
6695 RETURNING
6696 id, intent_id, chain_id, transaction_hash, payload_expected,
6697 raw_transaction, sealed_transaction, status,
6698 block_number, block_hash, receipt_success, gas_used,
6699 effective_gas_price, current
6700 ",
6701 )
6702 .bind(intent_id)
6703 .bind(chain_id_db)
6704 .bind(transaction_hash)
6705 .bind(raw_transaction)
6706 .bind(sealed_transaction)
6707 .fetch_optional(&mut *transaction)
6708 .await
6709 .map_err(|e| {
6710 anyhow::anyhow!("Failed to persist signed transaction {transaction_hash}: {e}")
6711 })?
6712 .ok_or_else(|| {
6713 anyhow::anyhow!(
6714 "Signed transaction {transaction_hash} conflicts with its persisted identity"
6715 )
6716 })?;
6717
6718 sqlx::query(
6719 "UPDATE execution_intent SET status = 'signed', updated_at = NOW() WHERE id = $1",
6720 )
6721 .bind(intent_id)
6722 .execute(&mut *transaction)
6723 .await
6724 .map_err(|e| anyhow::anyhow!("Failed to mark execution intent signed: {e}"))?;
6725 sqlx::query(
6726 "
6727 INSERT INTO execution_transaction_transition (
6728 intent_id, transaction_hash_id, transition_key, from_status, to_status
6729 ) VALUES ($1, $2, $3, $4, 'signed')
6730 ON CONFLICT (intent_id, transition_key) DO NOTHING
6731 ",
6732 )
6733 .bind(intent_id)
6734 .bind(row.id)
6735 .bind(format!("signed:{transaction_hash}"))
6736 .bind(current_status)
6737 .execute(&mut *transaction)
6738 .await
6739 .map_err(|e| anyhow::anyhow!("Failed to record signed transaction transition: {e}"))?;
6740
6741 transaction
6742 .commit()
6743 .await
6744 .map_err(|e| anyhow::anyhow!("Failed to commit signed transaction: {e}"))?;
6745 Ok(row)
6746 }
6747
6748 #[expect(
6754 clippy::too_many_arguments,
6755 reason = "the parameters are the canonical receipt observation persisted atomically"
6756 )]
6757 pub async fn record_execution_status(
6758 &self,
6759 intent_id: i64,
6760 transaction_hash: &str,
6761 status: TransactionStatus,
6762 block_number: Option<u64>,
6763 block_hash: Option<&str>,
6764 receipt_success: Option<bool>,
6765 gas_used: Option<u64>,
6766 effective_gas_price: Option<&str>,
6767 ) -> anyhow::Result<()> {
6768 let block_number_db = block_number
6769 .map(i64::try_from)
6770 .transpose()
6771 .with_context(|| {
6772 format!(
6773 "Execution block number {} exceeds PostgreSQL BIGINT",
6774 block_number.unwrap_or_default()
6775 )
6776 })?;
6777 let gas_used_db = gas_used.map(i64::try_from).transpose().with_context(|| {
6778 format!(
6779 "Execution gas used {} exceeds PostgreSQL BIGINT",
6780 gas_used.unwrap_or_default()
6781 )
6782 })?;
6783 let mut transaction = self
6784 .pool
6785 .begin()
6786 .await
6787 .map_err(|e| anyhow::anyhow!("Failed to start execution status transition: {e}"))?;
6788 let (current_status, fill_emitted, terminal_emitted) =
6789 sqlx::query_as::<_, (String, bool, bool)>(
6790 "SELECT status, fill_emitted, terminal_emitted FROM execution_intent WHERE id = $1 FOR UPDATE",
6791 )
6792 .bind(intent_id)
6793 .fetch_optional(&mut *transaction)
6794 .await
6795 .map_err(|e| anyhow::anyhow!("Failed to lock execution intent {intent_id}: {e}"))?
6796 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))?;
6797 anyhow::ensure!(
6798 execution_transition_allowed(¤t_status, status),
6799 "Invalid execution transition for intent {intent_id}: {current_status} -> {}",
6800 status.as_str()
6801 );
6802
6803 let active = match status {
6804 TransactionStatus::Finalized | TransactionStatus::Reverted => {
6805 !fill_emitted && !terminal_emitted
6806 }
6807 TransactionStatus::Recoverable => false,
6808 _ => true,
6809 };
6810 let hash_result = sqlx::query(
6811 "
6812 UPDATE execution_transaction_hash
6813 SET status = $3,
6814 block_number = COALESCE($4, block_number),
6815 block_hash = COALESCE($5, block_hash),
6816 receipt_success = COALESCE($6, receipt_success),
6817 gas_used = COALESCE($7, gas_used),
6818 effective_gas_price = COALESCE($8, effective_gas_price),
6819 updated_at = NOW()
6820 WHERE intent_id = $1 AND transaction_hash = $2
6821 ",
6822 )
6823 .bind(intent_id)
6824 .bind(transaction_hash)
6825 .bind(status.as_str())
6826 .bind(block_number_db)
6827 .bind(block_hash)
6828 .bind(receipt_success)
6829 .bind(gas_used_db)
6830 .bind(effective_gas_price)
6831 .execute(&mut *transaction)
6832 .await
6833 .map_err(|e| anyhow::anyhow!("Failed to update execution hash {transaction_hash}: {e}"))?;
6834 anyhow::ensure!(
6835 hash_result.rows_affected() == 1,
6836 "Execution transaction hash {transaction_hash} was not found for intent {intent_id}"
6837 );
6838
6839 sqlx::query(
6840 "
6841 UPDATE execution_intent
6842 SET status = $2, active = $3, updated_at = NOW()
6843 WHERE id = $1
6844 ",
6845 )
6846 .bind(intent_id)
6847 .bind(status.as_str())
6848 .bind(active)
6849 .execute(&mut *transaction)
6850 .await
6851 .map_err(|e| anyhow::anyhow!("Failed to update execution intent {intent_id}: {e}"))?;
6852
6853 let transition_key = format!(
6854 "{}:{transaction_hash}:{}:{}",
6855 status.as_str(),
6856 block_number.map_or_else(|| "none".to_string(), |value| value.to_string()),
6857 block_hash.unwrap_or("none")
6858 );
6859 sqlx::query(
6860 "
6861 INSERT INTO execution_transaction_transition (
6862 intent_id, transaction_hash_id, transition_key, from_status, to_status,
6863 block_number, block_hash
6864 )
6865 SELECT $1, id, $3, $4, $5, $6, $7
6866 FROM execution_transaction_hash
6867 WHERE intent_id = $1 AND transaction_hash = $2
6868 ON CONFLICT (intent_id, transition_key) DO NOTHING
6869 ",
6870 )
6871 .bind(intent_id)
6872 .bind(transaction_hash)
6873 .bind(transition_key)
6874 .bind(current_status)
6875 .bind(status.as_str())
6876 .bind(block_number_db)
6877 .bind(block_hash)
6878 .execute(&mut *transaction)
6879 .await
6880 .map_err(|e| anyhow::anyhow!("Failed to record execution transition: {e}"))?;
6881
6882 transaction
6883 .commit()
6884 .await
6885 .map_err(|e| anyhow::anyhow!("Failed to commit execution transition: {e}"))?;
6886 Ok(())
6887 }
6888
6889 pub(crate) async fn record_execution_finality_verified(
6896 &self,
6897 finality: &ExecutionFinalityTransition<'_>,
6898 ) -> anyhow::Result<()> {
6899 anyhow::ensure!(
6900 matches!(
6901 finality.status,
6902 TransactionStatus::Finalized | TransactionStatus::Reverted
6903 ),
6904 "Verified finality requires a finalized or reverted status"
6905 );
6906 anyhow::ensure!(
6907 !finality.decisions.is_empty(),
6908 "Verified finality requires decision evidence"
6909 );
6910 anyhow::ensure!(
6911 !finality.finalized_headers.is_empty()
6912 && finality.finalized_headers.windows(2).all(|headers| {
6913 headers[1].number == headers[0].number.saturating_add(1)
6914 && headers[1].parent_hash == headers[0].hash
6915 })
6916 && finality
6917 .finalized_headers
6918 .last()
6919 .is_some_and(|header| header.number >= finality.block_number),
6920 "Verified finality headers must form a continuous chain through the inclusion height"
6921 );
6922 let chain_id = i32::try_from(finality.chain_id)
6923 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
6924 let nonce =
6925 i64::try_from(finality.nonce).context("Execution nonce exceeds PostgreSQL BIGINT")?;
6926 let next_nonce = nonce
6927 .checked_add(1)
6928 .ok_or_else(|| anyhow::anyhow!("Canonical nonce overflow"))?;
6929 let block_number = i64::try_from(finality.block_number)
6930 .context("Finality block exceeds PostgreSQL BIGINT")?;
6931 let gas_used = i64::try_from(finality.gas_used)
6932 .context("Finality gas used exceeds PostgreSQL BIGINT")?;
6933 let mut transaction =
6934 self.pool.begin().await.map_err(|e| {
6935 anyhow::anyhow!("Failed to start verified finality transition: {e}")
6936 })?;
6937 let (manifest_version, manifest_digest, stored_nonce, revision) =
6938 sqlx::query_as::<_, (String, String, i64, i64)>(
6939 "
6940 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
6941 FROM execution_verification_nonce
6942 WHERE chain_id = $1 AND wallet_address = $2
6943 FOR UPDATE
6944 ",
6945 )
6946 .bind(chain_id)
6947 .bind(finality.wallet_address)
6948 .fetch_optional(&mut *transaction)
6949 .await
6950 .map_err(|e| anyhow::anyhow!("Failed to lock finality nonce ledger: {e}"))?
6951 .ok_or_else(|| anyhow::anyhow!("Canonical nonce ledger is not initialized"))?;
6952 anyhow::ensure!(
6953 manifest_version == finality.manifest_version
6954 && manifest_digest == finality.manifest_digest,
6955 "Verified finality manifest identity changed"
6956 );
6957 anyhow::ensure!(
6958 stored_nonce == nonce,
6959 "Finalized nonce {} does not match canonical nonce {stored_nonce}",
6960 finality.nonce
6961 );
6962 let stored_tip = sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
6963 "
6964 SELECT number, hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
6965 FROM execution_verified_finalized_header
6966 WHERE chain_id = $1 AND wallet_address = $2
6967 ORDER BY number DESC
6968 LIMIT 1
6969 ",
6970 )
6971 .bind(chain_id)
6972 .bind(finality.wallet_address)
6973 .fetch_one(&mut *transaction)
6974 .await
6975 .map_err(|e| anyhow::anyhow!("Failed to lock finalized header tip: {e}"))?;
6976 let first_header = &finality.finalized_headers[0];
6977 anyhow::ensure!(
6978 stored_tip
6979 == (
6980 i64::try_from(first_header.number)
6981 .context("Verified finalized height exceeds PostgreSQL BIGINT")?,
6982 first_header.hash.clone(),
6983 first_header.parent_hash.clone(),
6984 i64::try_from(first_header.timestamp)
6985 .context("Verified finalized timestamp exceeds PostgreSQL BIGINT")?,
6986 first_header.base_fee_per_gas.map(|value| value.to_string()),
6987 finality.manifest_digest.to_string(),
6988 ),
6989 "Verified finality extension does not start at the durable tip"
6990 );
6991
6992 for header in finality.finalized_headers.iter().skip(1) {
6993 let number = i64::try_from(header.number)
6994 .context("Verified finalized height exceeds PostgreSQL BIGINT")?;
6995 let timestamp = i64::try_from(header.timestamp)
6996 .context("Verified finalized timestamp exceeds PostgreSQL BIGINT")?;
6997 let base_fee = header.base_fee_per_gas.map(|value| value.to_string());
6998 sqlx::query(
6999 "
7000 INSERT INTO execution_verified_finalized_header (
7001 chain_id, wallet_address, number, hash, parent_hash, timestamp,
7002 base_fee_per_gas, manifest_digest
7003 )
7004 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
7005 ON CONFLICT (chain_id, wallet_address, number) DO NOTHING
7006 ",
7007 )
7008 .bind(chain_id)
7009 .bind(finality.wallet_address)
7010 .bind(number)
7011 .bind(&header.hash)
7012 .bind(&header.parent_hash)
7013 .bind(timestamp)
7014 .bind(&base_fee)
7015 .bind(finality.manifest_digest)
7016 .execute(&mut *transaction)
7017 .await
7018 .map_err(|e| anyhow::anyhow!("Failed to extend finalized header ledger: {e}"))?;
7019 let stored = sqlx::query_as::<_, (String, String, i64, Option<String>, String)>(
7020 "
7021 SELECT hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
7022 FROM execution_verified_finalized_header
7023 WHERE chain_id = $1 AND wallet_address = $2 AND number = $3
7024 ",
7025 )
7026 .bind(chain_id)
7027 .bind(finality.wallet_address)
7028 .bind(number)
7029 .fetch_one(&mut *transaction)
7030 .await
7031 .map_err(|e| anyhow::anyhow!("Failed to validate finalized header ledger: {e}"))?;
7032 anyhow::ensure!(
7033 stored
7034 == (
7035 header.hash.clone(),
7036 header.parent_hash.clone(),
7037 timestamp,
7038 base_fee,
7039 finality.manifest_digest.to_string(),
7040 ),
7041 "Finalized header ledger conflicts at height {}",
7042 header.number
7043 );
7044 }
7045
7046 let (current_status, intent_nonce, fill_emitted, terminal_emitted) =
7047 sqlx::query_as::<_, (String, Option<i64>, bool, bool)>(
7048 "
7049 SELECT status, nonce, fill_emitted, terminal_emitted
7050 FROM execution_intent
7051 WHERE id = $1 AND chain_id = $2 AND wallet_address = $3 AND active
7052 FOR UPDATE
7053 ",
7054 )
7055 .bind(finality.intent_id)
7056 .bind(chain_id)
7057 .bind(finality.wallet_address)
7058 .fetch_optional(&mut *transaction)
7059 .await
7060 .map_err(|e| anyhow::anyhow!("Failed to lock intent for verified finality: {e}"))?
7061 .ok_or_else(|| anyhow::anyhow!("Active finality intent was not found"))?;
7062 anyhow::ensure!(
7063 intent_nonce == Some(nonce)
7064 && execution_transition_allowed(¤t_status, finality.status),
7065 "Intent cannot make the verified finality transition"
7066 );
7067
7068 for (index, decision) in finality.decisions.iter().enumerate() {
7069 let height_start = decision
7070 .height_start
7071 .map(i64::try_from)
7072 .transpose()
7073 .context("Verification height exceeds PostgreSQL BIGINT")?;
7074 let height_end = decision
7075 .height_end
7076 .map(i64::try_from)
7077 .transpose()
7078 .context("Verification height exceeds PostgreSQL BIGINT")?;
7079 let transition_key = format!(
7080 "finality:{}:{}:{index}",
7081 finality.intent_id, decision.read_class
7082 );
7083 sqlx::query(
7084 "
7085 INSERT INTO execution_verification_decision (
7086 intent_id, nonce, decision_class, read_class, height_start, height_end,
7087 manifest_version, manifest_digest, provider_ids, operator_ids,
7088 failure_domain_ids, response_class, normalized_value_digest,
7089 nonce_revision, outcome, transition_key
7090 )
7091 VALUES (
7092 $1, $2, 'finality', $3, $4, $5, $6, $7, $8, $9, $10,
7093 'all_valid', $11, $12, 'verified', $13
7094 )
7095 ",
7096 )
7097 .bind(finality.intent_id)
7098 .bind(nonce)
7099 .bind(decision.read_class)
7100 .bind(height_start)
7101 .bind(height_end)
7102 .bind(finality.manifest_version)
7103 .bind(finality.manifest_digest)
7104 .bind(finality.provider_ids)
7105 .bind(finality.operator_ids)
7106 .bind(finality.failure_domain_ids)
7107 .bind(&decision.normalized_value_digest)
7108 .bind(revision)
7109 .bind(transition_key)
7110 .execute(&mut *transaction)
7111 .await
7112 .map_err(|e| anyhow::anyhow!("Failed to persist finality verification: {e}"))?;
7113 }
7114
7115 let hash_result = sqlx::query(
7116 "
7117 UPDATE execution_transaction_hash
7118 SET status = $3, block_number = $4, block_hash = $5,
7119 receipt_success = $6, gas_used = $7, effective_gas_price = $8,
7120 updated_at = NOW()
7121 WHERE intent_id = $1 AND transaction_hash = $2
7122 ",
7123 )
7124 .bind(finality.intent_id)
7125 .bind(finality.transaction_hash)
7126 .bind(finality.status.as_str())
7127 .bind(block_number)
7128 .bind(finality.block_hash)
7129 .bind(finality.receipt_success)
7130 .bind(gas_used)
7131 .bind(finality.effective_gas_price)
7132 .execute(&mut *transaction)
7133 .await
7134 .map_err(|e| anyhow::anyhow!("Failed to record verified finality receipt: {e}"))?;
7135 anyhow::ensure!(
7136 hash_result.rows_affected() == 1,
7137 "Finality transaction hash was not found"
7138 );
7139
7140 let active = !fill_emitted && !terminal_emitted;
7141 sqlx::query(
7142 "UPDATE execution_intent SET status = $2, active = $3, updated_at = NOW() WHERE id = $1",
7143 )
7144 .bind(finality.intent_id)
7145 .bind(finality.status.as_str())
7146 .bind(active)
7147 .execute(&mut *transaction)
7148 .await
7149 .map_err(|e| anyhow::anyhow!("Failed to record verified finality intent: {e}"))?;
7150
7151 let transition_key = format!(
7152 "{}:{}:{}:{}",
7153 finality.status.as_str(),
7154 finality.transaction_hash,
7155 finality.block_number,
7156 finality.block_hash
7157 );
7158 sqlx::query(
7159 "
7160 INSERT INTO execution_transaction_transition (
7161 intent_id, transaction_hash_id, transition_key, from_status, to_status,
7162 block_number, block_hash
7163 )
7164 SELECT $1, id, $3, $4, $5, $6, $7
7165 FROM execution_transaction_hash
7166 WHERE intent_id = $1 AND transaction_hash = $2
7167 ",
7168 )
7169 .bind(finality.intent_id)
7170 .bind(finality.transaction_hash)
7171 .bind(transition_key)
7172 .bind(current_status)
7173 .bind(finality.status.as_str())
7174 .bind(block_number)
7175 .bind(finality.block_hash)
7176 .execute(&mut *transaction)
7177 .await
7178 .map_err(|e| anyhow::anyhow!("Failed to record verified finality transition: {e}"))?;
7179
7180 let nonce_result = sqlx::query(
7181 "
7182 UPDATE execution_verification_nonce
7183 SET next_canonical_nonce = $3, revision = revision + 1, updated_at = NOW()
7184 WHERE chain_id = $1 AND wallet_address = $2
7185 AND next_canonical_nonce = $4 AND revision = $5
7186 ",
7187 )
7188 .bind(chain_id)
7189 .bind(finality.wallet_address)
7190 .bind(next_nonce)
7191 .bind(nonce)
7192 .bind(revision)
7193 .execute(&mut *transaction)
7194 .await
7195 .map_err(|e| anyhow::anyhow!("Failed to advance canonical nonce ledger: {e}"))?;
7196 anyhow::ensure!(
7197 nonce_result.rows_affected() == 1,
7198 "Canonical nonce ledger changed during finality transition"
7199 );
7200 transaction
7201 .commit()
7202 .await
7203 .map_err(|e| anyhow::anyhow!("Failed to commit verified finality transition: {e}"))?;
7204 Ok(())
7205 }
7206
7207 pub(crate) async fn get_execution_intent(
7209 &self,
7210 intent_id: i64,
7211 ) -> anyhow::Result<ExecutionIntentRow> {
7212 sqlx::query_as::<_, ExecutionIntentRow>(
7213 "
7214 SELECT
7215 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
7216 client_order_id, trader_id, strategy_id, account_id, instrument_id,
7217 pool_address, transaction_to, transaction_input, transaction_value,
7218 amount_in, created_block, acknowledgement_emitted, fill_emitted,
7219 terminal_emitted, active
7220 FROM execution_intent
7221 WHERE id = $1
7222 ",
7223 )
7224 .bind(intent_id)
7225 .fetch_optional(&self.pool)
7226 .await
7227 .context("failed to load execution intent")?
7228 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))
7229 }
7230
7231 pub async fn get_active_execution_intent(
7237 &self,
7238 chain_id: u32,
7239 wallet_address: &str,
7240 ) -> anyhow::Result<Option<ExecutionIntentRow>> {
7241 let chain_id_db = i32::try_from(chain_id)
7242 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
7243 let mut transaction = self
7244 .pool
7245 .begin_with("BEGIN ISOLATION LEVEL READ COMMITTED")
7246 .await
7247 .context("failed to start active execution intent reconciliation")?;
7248 Self::lock_execution_signer(&mut transaction, chain_id, wallet_address).await?;
7249 let intent = sqlx::query_as::<_, ExecutionIntentRow>(
7250 "
7251 SELECT
7252 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
7253 client_order_id, trader_id, strategy_id, account_id, instrument_id,
7254 pool_address, transaction_to, transaction_input, transaction_value,
7255 amount_in, created_block, acknowledgement_emitted, fill_emitted,
7256 terminal_emitted, active
7257 FROM execution_intent
7258 WHERE chain_id = $1 AND wallet_address = $2 AND active
7259 ",
7260 )
7261 .bind(chain_id_db)
7262 .bind(wallet_address)
7263 .fetch_optional(&mut *transaction)
7264 .await
7265 .context("failed to load active execution intent")?;
7266 transaction
7267 .commit()
7268 .await
7269 .context("failed to complete active execution intent reconciliation")?;
7270 Ok(intent)
7271 }
7272
7273 async fn lock_execution_signer(
7274 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
7275 chain_id: u32,
7276 wallet_address: &str,
7277 ) -> anyhow::Result<()> {
7278 let lock = PgAdvisoryLock::new(format!(
7279 "nautilus:blockchain:execution:{chain_id}:{}",
7280 wallet_address.to_ascii_lowercase()
7281 ));
7282 let PgAdvisoryLockKey::BigInt(lock_key) = lock.key() else {
7283 unreachable!("string advisory locks use the 64-bit key space");
7284 };
7285
7286 sqlx::query("SELECT pg_advisory_xact_lock($1)")
7289 .bind(*lock_key)
7290 .execute(&mut **transaction)
7291 .await
7292 .context("failed to acquire execution signer reservation fence")?;
7293 Ok(())
7294 }
7295
7296 pub async fn has_recoverable_signed_execution(
7302 &self,
7303 chain_id: u32,
7304 wallet_address: &str,
7305 ) -> anyhow::Result<bool> {
7306 let chain_id_db = i32::try_from(chain_id)
7307 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
7308 sqlx::query_scalar::<_, bool>(
7309 "
7310 SELECT EXISTS (
7311 SELECT 1
7312 FROM execution_intent AS intent
7313 JOIN execution_transaction_hash AS hash ON hash.intent_id = intent.id
7314 WHERE intent.chain_id = $1
7315 AND intent.wallet_address = $2
7316 AND intent.status = 'recoverable'
7317 AND (hash.raw_transaction IS NOT NULL OR hash.sealed_transaction IS NOT NULL)
7318 )
7319 ",
7320 )
7321 .bind(chain_id_db)
7322 .bind(wallet_address)
7323 .fetch_one(&self.pool)
7324 .await
7325 .map_err(|e| anyhow::anyhow!("Failed to inspect recoverable signed executions: {e}"))
7326 }
7327
7328 pub async fn get_execution_transaction_hashes(
7334 &self,
7335 intent_id: i64,
7336 ) -> anyhow::Result<Vec<ExecutionTransactionHashRow>> {
7337 sqlx::query_as::<_, ExecutionTransactionHashRow>(
7338 "
7339 SELECT
7340 id, intent_id, chain_id, transaction_hash, payload_expected,
7341 raw_transaction, sealed_transaction, status,
7342 block_number, block_hash, receipt_success, gas_used,
7343 effective_gas_price, current
7344 FROM execution_transaction_hash
7345 WHERE intent_id = $1
7346 ORDER BY id
7347 ",
7348 )
7349 .bind(intent_id)
7350 .fetch_all(&self.pool)
7351 .await
7352 .map_err(|e| anyhow::anyhow!("Failed to load execution transaction hashes: {e}"))
7353 }
7354
7355 pub async fn mark_execution_event_emitted(
7362 &self,
7363 intent_id: i64,
7364 event: &str,
7365 ) -> anyhow::Result<()> {
7366 let statement = match event {
7367 "acknowledgement" => {
7368 "UPDATE execution_intent SET acknowledgement_emitted = TRUE, updated_at = NOW() WHERE id = $1"
7369 }
7370 "fill" => {
7371 "UPDATE execution_intent SET fill_emitted = TRUE, active = CASE WHEN status = 'finalized' THEN FALSE ELSE active END, updated_at = NOW() WHERE id = $1 AND NOT terminal_emitted"
7372 }
7373 "terminal" => {
7374 "UPDATE execution_intent SET terminal_emitted = TRUE, active = CASE WHEN status IN ('finalized', 'reverted') THEN FALSE ELSE active END, updated_at = NOW() WHERE id = $1 AND NOT fill_emitted"
7375 }
7376 _ => anyhow::bail!("Unknown execution event marker {event}"),
7377 };
7378 let result = sqlx::query(statement)
7379 .bind(intent_id)
7380 .execute(&self.pool)
7381 .await
7382 .map_err(|e| anyhow::anyhow!("Failed to mark execution {event} emitted: {e}"))?;
7383 anyhow::ensure!(
7384 result.rows_affected() == 1,
7385 "Execution intent {intent_id} cannot mark {event} emitted"
7386 );
7387 Ok(())
7388 }
7389
7390 pub async fn update_execution_transaction_status(
7396 &self,
7397 chain_id: u32,
7398 transaction_hash: &str,
7399 status: &str,
7400 ) -> anyhow::Result<()> {
7401 let result = sqlx::query(
7402 "
7403 UPDATE execution_transaction
7404 SET status = $3
7405 WHERE chain_id = $1 AND transaction_hash = $2
7406 ",
7407 )
7408 .bind(chain_id as i32)
7409 .bind(transaction_hash)
7410 .bind(status)
7411 .execute(&self.pool)
7412 .await
7413 .map_err(|e| anyhow::anyhow!("Failed to update execution_transaction table: {e}"))?;
7414
7415 anyhow::ensure!(
7416 result.rows_affected() == 1,
7417 "Execution transaction {transaction_hash} was not found for status update"
7418 );
7419 Ok(())
7420 }
7421
7422 pub async fn get_execution_transaction(
7428 &self,
7429 chain_id: u32,
7430 transaction_hash: &str,
7431 ) -> anyhow::Result<Option<ExecutionTransactionRow>> {
7432 let chain_id_db = i32::try_from(chain_id)
7433 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
7434 sqlx::query_as::<_, ExecutionTransactionRow>(
7435 "
7436 SELECT wallet_address, nonce, transaction_hash, purpose, status, client_order_id
7437 FROM (
7438 SELECT
7439 intent.wallet_address,
7440 intent.nonce,
7441 hash.transaction_hash,
7442 intent.purpose,
7443 intent.status,
7444 intent.client_order_id,
7445 0 AS source_priority
7446 FROM execution_transaction_hash AS hash
7447 JOIN execution_intent AS intent ON intent.id = hash.intent_id
7448 WHERE hash.chain_id = $1 AND hash.transaction_hash = $2
7449 UNION ALL
7450 SELECT
7451 wallet_address,
7452 nonce,
7453 transaction_hash,
7454 purpose,
7455 status,
7456 client_order_id,
7457 1 AS source_priority
7458 FROM execution_transaction
7459 WHERE chain_id = $1 AND transaction_hash = $2
7460 ) AS record
7461 ORDER BY source_priority
7462 LIMIT 1
7463 ",
7464 )
7465 .bind(chain_id_db)
7466 .bind(transaction_hash)
7467 .fetch_optional(&self.pool)
7468 .await
7469 .map_err(|e| anyhow::anyhow!("Failed to load from execution_transaction table: {e}"))
7470 }
7471}
7472
7473fn execution_payload_state_from_row(
7474 row: &sqlx::postgres::PgRow,
7475) -> anyhow::Result<ExecutionPayloadState> {
7476 Ok(ExecutionPayloadState {
7477 deployment_id: row.try_get("deployment_id")?,
7478 protocol_version: row.try_get("protocol_version")?,
7479 operation: row.try_get("operation")?,
7480 active_key_id: row.try_get("active_key_id")?,
7481 })
7482}
7483
7484fn validate_execution_payload_state(
7485 state: &ExecutionPayloadState,
7486 keys: &PayloadKeySet,
7487) -> anyhow::Result<()> {
7488 anyhow::ensure!(
7489 state.protocol_version == EXECUTION_PAYLOAD_PROTOCOL_VERSION,
7490 "Execution payload protocol version {} is not supported",
7491 state.protocol_version
7492 );
7493 anyhow::ensure!(
7494 state.deployment_id == keys.deployment_id(),
7495 "Execution payload deployment ID does not match this database"
7496 );
7497 anyhow::ensure!(
7498 state.active_key_id.as_slice() == keys.active_key_id(),
7499 "Configured active payload key does not match the database active key"
7500 );
7501 Ok(())
7502}
7503
7504async fn lock_execution_payload_operation(
7505 transaction: &mut Transaction<'_, Postgres>,
7506) -> anyhow::Result<()> {
7507 let lock = PgAdvisoryLock::new("nautilus:blockchain:execution-payload");
7508 let PgAdvisoryLockKey::BigInt(lock_key) = lock.key() else {
7509 unreachable!("string advisory locks use the 64-bit key space");
7510 };
7511 sqlx::query("SELECT pg_advisory_xact_lock($1)")
7512 .bind(*lock_key)
7513 .execute(&mut **transaction)
7514 .await
7515 .context("failed to acquire execution payload operation fence")?;
7516 Ok(())
7517}
7518
7519async fn reserve_execution_payload_seal(
7520 transaction: &mut Transaction<'_, Postgres>,
7521 key_id: &[u8; 32],
7522) -> anyhow::Result<()> {
7523 let seals = sqlx::query_scalar::<_, i64>(
7524 "INSERT INTO execution_payload_key_state (key_id, seals) VALUES ($1, 1) \
7525 ON CONFLICT (key_id) DO UPDATE SET seals = execution_payload_key_state.seals + 1 \
7526 WHERE execution_payload_key_state.seals < $2 \
7527 RETURNING seals",
7528 )
7529 .bind(key_id.as_slice())
7530 .bind(EXECUTION_PAYLOAD_MAX_SEALS - 1)
7531 .fetch_optional(&mut **transaction)
7532 .await
7533 .context("failed to reserve execution payload nonce use")?;
7534 anyhow::ensure!(
7535 seals.is_some(),
7536 "Execution payload key reached its seal limit; rotate the active key before continuing"
7537 );
7538 Ok(())
7539}
7540
7541async fn validate_execution_payload_key_inventory(
7542 transaction: &mut Transaction<'_, Postgres>,
7543 keys: &PayloadKeySet,
7544) -> anyhow::Result<()> {
7545 let envelopes = sqlx::query_scalar::<_, Vec<u8>>(
7546 "SELECT DISTINCT substring(sealed_transaction FROM 1 FOR 33) \
7547 FROM execution_transaction_hash \
7548 WHERE sealed_transaction IS NOT NULL",
7549 )
7550 .fetch_all(&mut **transaction)
7551 .await
7552 .context("failed to inspect execution payload key inventory")?;
7553
7554 for header in envelopes {
7555 anyhow::ensure!(
7556 header.len() == 33,
7557 "Stored execution payload has a truncated envelope header"
7558 );
7559 let mut envelope = header;
7560 envelope.extend_from_slice(&[0; 12 + 16]);
7561 let key_id = envelope_key_id(&envelope)?;
7562 anyhow::ensure!(
7563 keys.contains_key(&key_id),
7564 "Stored execution payload requires unavailable key {}",
7565 alloy::hex::encode(key_id)
7566 );
7567 }
7568 Ok(())
7569}
7570
7571async fn load_execution_intent(
7572 transaction: &mut Transaction<'_, Postgres>,
7573 intent_id: i64,
7574) -> anyhow::Result<ExecutionIntentRow> {
7575 sqlx::query_as::<_, ExecutionIntentRow>(
7576 "
7577 SELECT
7578 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
7579 client_order_id, trader_id, strategy_id, account_id, instrument_id,
7580 pool_address, transaction_to, transaction_input, transaction_value,
7581 amount_in, created_block, acknowledgement_emitted, fill_emitted,
7582 terminal_emitted, active
7583 FROM execution_intent
7584 WHERE id = $1
7585 FOR SHARE
7586 ",
7587 )
7588 .bind(intent_id)
7589 .fetch_optional(&mut **transaction)
7590 .await
7591 .context("failed to load execution intent for payload authentication")?
7592 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))
7593}
7594
7595fn execution_transition_allowed(current: &str, next: TransactionStatus) -> bool {
7596 if current == next.as_str() {
7597 return true;
7598 }
7599
7600 match current {
7601 "prepared" => matches!(
7602 next,
7603 TransactionStatus::Signed | TransactionStatus::Recoverable
7604 ),
7605 "signed" | "broadcast" => matches!(
7606 next,
7607 TransactionStatus::Broadcast
7608 | TransactionStatus::Included
7609 | TransactionStatus::Finalized
7610 | TransactionStatus::Reverted
7611 | TransactionStatus::Replaced
7612 | TransactionStatus::Dropped
7613 | TransactionStatus::Reorged
7614 ),
7615 "included" => matches!(
7616 next,
7617 TransactionStatus::Finalized
7618 | TransactionStatus::Reverted
7619 | TransactionStatus::Reorged
7620 | TransactionStatus::Replaced
7621 | TransactionStatus::Dropped
7622 ),
7623 "replaced" | "dropped" | "reorged" => matches!(
7624 next,
7625 TransactionStatus::Included
7626 | TransactionStatus::Finalized
7627 | TransactionStatus::Reverted
7628 | TransactionStatus::Replaced
7629 | TransactionStatus::Dropped
7630 | TransactionStatus::Reorged
7631 ),
7632 "finalized" | "reverted" | "recoverable" => false,
7633 _ => false,
7634 }
7635}
7636
7637#[cfg(test)]
7638pub(crate) mod tests {
7639 use std::time::Duration;
7640
7641 use anyhow::Context;
7642 use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
7643
7644 use super::{BlockchainCacheDatabase, ExecutionVerifiedHeader};
7645 use crate::rpc::verification::VERIFICATION_SCHEMA_VERSION;
7646
7647 pub(crate) struct ExecutionVerificationResume {
7648 pub next_canonical_nonce: u64,
7649 pub revision: u64,
7650 pub finalized_headers: Vec<ExecutionVerifiedHeader>,
7651 }
7652
7653 pub(crate) async fn connect_test_database(
7654 pg_options: PgConnectOptions,
7655 ) -> anyhow::Result<BlockchainCacheDatabase> {
7656 let pool = PgPoolOptions::new()
7657 .max_connections(32)
7658 .min_connections(1)
7659 .acquire_timeout(Duration::from_secs(3))
7660 .connect_with(pg_options)
7661 .await?;
7662 Ok(BlockchainCacheDatabase { pool })
7663 }
7664
7665 impl BlockchainCacheDatabase {
7666 pub(crate) async fn load_execution_verification_resume(
7668 &self,
7669 chain_id: u32,
7670 wallet_address: &str,
7671 manifest_version: &str,
7672 manifest_digest: &str,
7673 ) -> anyhow::Result<Option<ExecutionVerificationResume>> {
7674 let installed = sqlx::query_scalar::<_, i16>(
7675 "SELECT version FROM execution_schema_version \
7676 WHERE component = 'evm_execution_verification'",
7677 )
7678 .fetch_optional(&self.pool)
7679 .await
7680 .context("failed to inspect execution verification schema")?;
7681 let Some(installed) = installed else {
7682 return Ok(None);
7683 };
7684 anyhow::ensure!(
7685 installed <= VERIFICATION_SCHEMA_VERSION,
7686 "Unsupported execution verification schema version {installed}"
7687 );
7688 let chain_id = i32::try_from(chain_id)
7689 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
7690 let current = sqlx::query_as::<_, (String, String, i64, i64)>(
7691 "
7692 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
7693 FROM execution_verification_nonce
7694 WHERE chain_id = $1 AND wallet_address = $2
7695 ",
7696 )
7697 .bind(chain_id)
7698 .bind(wallet_address)
7699 .fetch_optional(&self.pool)
7700 .await
7701 .context("failed to load execution verification nonce position")?;
7702 let Some((stored_version, stored_digest, nonce, revision)) = current else {
7703 return Ok(None);
7704 };
7705 anyhow::ensure!(
7706 stored_version == manifest_version && stored_digest == manifest_digest,
7707 "Execution verification manifest identity changed"
7708 );
7709 let rows = sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
7710 "
7711 SELECT number, hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
7712 FROM execution_verified_finalized_header
7713 WHERE chain_id = $1 AND wallet_address = $2
7714 ORDER BY number
7715 ",
7716 )
7717 .bind(chain_id)
7718 .bind(wallet_address)
7719 .fetch_all(&self.pool)
7720 .await
7721 .context("failed to load verified finalized header ledger")?;
7722 let finalized_headers = rows
7723 .into_iter()
7724 .map(|(number, hash, parent_hash, timestamp, base_fee, digest)| {
7725 anyhow::ensure!(
7726 digest == manifest_digest,
7727 "Finalized header manifest identity changed"
7728 );
7729 Ok(ExecutionVerifiedHeader {
7730 number: u64::try_from(number)
7731 .context("Finalized header number is negative")?,
7732 hash,
7733 parent_hash,
7734 timestamp: u64::try_from(timestamp)
7735 .context("Finalized header timestamp is negative")?,
7736 base_fee_per_gas: base_fee
7737 .map(|value| {
7738 value.parse::<u128>().map_err(|_| {
7739 anyhow::anyhow!("Finalized header base fee is invalid")
7740 })
7741 })
7742 .transpose()?,
7743 })
7744 })
7745 .collect::<anyhow::Result<Vec<_>>>()?;
7746 anyhow::ensure!(
7747 !finalized_headers.is_empty()
7748 && finalized_headers.windows(2).all(|headers| {
7749 headers[1].number == headers[0].number.saturating_add(1)
7750 && headers[1].parent_hash == headers[0].hash
7751 }),
7752 "Verified finalized header ledger is not continuous"
7753 );
7754 Ok(Some(ExecutionVerificationResume {
7755 next_canonical_nonce: u64::try_from(nonce)
7756 .context("Canonical nonce is negative")?,
7757 revision: u64::try_from(revision)
7758 .context("Canonical nonce revision is negative")?,
7759 finalized_headers,
7760 }))
7761 }
7762 }
7763
7764 #[tokio::test]
7765 async fn connect_returns_err_for_unreachable_database() {
7766 let options = PgConnectOptions::new()
7769 .host("127.0.0.1")
7770 .port(1)
7771 .username("nautilus")
7772 .password("pass")
7773 .database("nautilus");
7774
7775 let result = BlockchainCacheDatabase::connect(options).await;
7776
7777 assert!(result.is_err());
7778 }
7779}