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 {pool_identifier} at block {block}"
2446 )
2447 })?;
2448 let timestamp = parse_cached_block_timestamp(&block_timestamp)
2449 .map_err(|e| anyhow::anyhow!("Invalid block timestamp '{block_timestamp}': {e}"))?;
2450
2451 let block_position =
2452 BlockPosition::new(block, transaction_hash, transaction_index, log_index)
2453 .with_block_hash(block_hash);
2454
2455 let fee_protocol_value = row.get::<i16, _>("fee_protocol");
2456 let fee_protocol = u8::try_from(fee_protocol_value).with_context(|| {
2457 format!("Invalid pool snapshot fee protocol {fee_protocol_value}")
2458 })?;
2459 let fee_protocol0_basis_points = row
2460 .get::<Option<i32>, _>("fee_protocol0_basis_points")
2461 .map(|value| {
2462 u32::try_from(value).with_context(|| {
2463 format!("Invalid token0 fee protocol basis points {value}")
2464 })
2465 })
2466 .transpose()?;
2467 let fee_protocol1_basis_points = row
2468 .get::<Option<i32>, _>("fee_protocol1_basis_points")
2469 .map(|value| {
2470 u32::try_from(value).with_context(|| {
2471 format!("Invalid token1 fee protocol basis points {value}")
2472 })
2473 })
2474 .transpose()?;
2475
2476 let state = PoolState {
2477 current_tick: row.get("current_tick"),
2478 price_sqrt_ratio_x96: row.get::<String, _>("price_sqrt_ratio_x96").parse()?,
2479 liquidity: row.get::<String, _>("liquidity").parse()?,
2480 protocol_fees_token0: row.get::<String, _>("protocol_fees_token0").parse()?,
2481 protocol_fees_token1: row.get::<String, _>("protocol_fees_token1").parse()?,
2482 fee_protocol,
2483 fee_protocol0_basis_points,
2484 fee_protocol1_basis_points,
2485 fee_growth_global_0: row.get::<String, _>("fee_growth_global_0").parse()?,
2486 fee_growth_global_1: row.get::<String, _>("fee_growth_global_1").parse()?,
2487 };
2488
2489 let analytics = PoolAnalytics {
2490 total_amount0_deposited: row.get::<String, _>("total_amount0_deposited").parse()?,
2491 total_amount1_deposited: row.get::<String, _>("total_amount1_deposited").parse()?,
2492 total_amount0_collected: row.get::<String, _>("total_amount0_collected").parse()?,
2493 total_amount1_collected: row.get::<String, _>("total_amount1_collected").parse()?,
2494 total_swaps: u64::try_from(row.get::<i32, _>("total_swaps"))
2495 .with_context(|| "Pool snapshot total swaps is negative")?,
2496 total_mints: u64::try_from(row.get::<i32, _>("total_mints"))
2497 .with_context(|| "Pool snapshot total mints is negative")?,
2498 total_burns: u64::try_from(row.get::<i32, _>("total_burns"))
2499 .with_context(|| "Pool snapshot total burns is negative")?,
2500 total_fee_collects: u64::try_from(row.get::<i32, _>("total_fee_collects"))
2501 .with_context(|| "Pool snapshot total fee collects is negative")?,
2502 total_flashes: u64::try_from(row.get::<i32, _>("total_flashes"))
2503 .with_context(|| "Pool snapshot total flashes is negative")?,
2504 liquidity_utilization_rate: row.get::<f64, _>("liquidity_utilization_rate"),
2505 };
2506
2507 let positions = self
2509 .load_pool_positions_for_snapshot(
2510 chain_id,
2511 pool_identifier,
2512 block,
2513 transaction_index,
2514 log_index,
2515 )
2516 .await?;
2517
2518 let ticks = self
2519 .load_pool_ticks_for_snapshot(
2520 chain_id,
2521 pool_identifier,
2522 block,
2523 transaction_index,
2524 log_index,
2525 )
2526 .await?;
2527
2528 let dex_name = row
2529 .try_get::<Option<String>, _>("dex_name")?
2530 .ok_or_else(|| {
2531 anyhow::anyhow!("Missing dex_name for pool snapshot {pool_identifier}")
2532 })?;
2533 let chain = Chain::from_chain_id(chain_id)
2534 .ok_or_else(|| anyhow::anyhow!("Unknown chain_id: {chain_id}"))?;
2535
2536 let dex_type = DexType::from_dex_name(&dex_name)
2537 .ok_or_else(|| anyhow::anyhow!("Unknown dex_name: {dex_name}"))?;
2538
2539 let dex_extended = crate::exchanges::get_dex_extended(chain.name, &dex_type)
2540 .ok_or_else(|| {
2541 anyhow::anyhow!("No DEX extended found for {} on {}", dex_name, chain.name)
2542 })?;
2543
2544 let instrument_id =
2545 Pool::create_instrument_id(chain.name, &dex_extended.dex, pool_identifier.as_ref());
2546
2547 Ok(Some(PoolSnapshot::new(
2548 instrument_id,
2549 state,
2550 positions,
2551 ticks,
2552 analytics,
2553 block_position,
2554 timestamp, timestamp, )))
2557 } else {
2558 Ok(None)
2559 }
2560 }
2561
2562 pub async fn set_pool_snapshot_validation_state(
2571 &self,
2572 chain_id: u32,
2573 pool_identifier: &PoolIdentifier,
2574 block: u64,
2575 transaction_index: u32,
2576 log_index: u32,
2577 state: &str,
2578 ) -> anyhow::Result<()> {
2579 sqlx::query(
2580 "
2581 UPDATE pool_snapshot
2582 SET validation_state = $6
2583 WHERE chain_id = $1
2584 AND pool_identifier = $2
2585 AND block = $3
2586 AND transaction_index = $4
2587 AND log_index = $5
2588 ",
2589 )
2590 .bind(chain_id as i32)
2591 .bind(pool_identifier.as_ref())
2592 .bind(block as i64)
2593 .bind(transaction_index as i32)
2594 .bind(log_index as i32)
2595 .bind(state)
2596 .execute(&self.pool)
2597 .await
2598 .map(|_| ())
2599 .map_err(|e| anyhow::anyhow!("Failed to set pool snapshot validation state: {e}"))
2600 }
2601
2602 pub async fn get_pool_snapshot_validation_state(
2611 &self,
2612 chain_id: u32,
2613 pool_identifier: &PoolIdentifier,
2614 block: u64,
2615 transaction_index: u32,
2616 log_index: u32,
2617 ) -> anyhow::Result<Option<String>> {
2618 let row = sqlx::query(
2619 "
2620 SELECT validation_state
2621 FROM pool_snapshot
2622 WHERE chain_id = $1
2623 AND pool_identifier = $2
2624 AND block = $3
2625 AND transaction_index = $4
2626 AND log_index = $5
2627 ",
2628 )
2629 .bind(chain_id as i32)
2630 .bind(pool_identifier.as_ref())
2631 .bind(block as i64)
2632 .bind(transaction_index as i32)
2633 .bind(log_index as i32)
2634 .fetch_optional(&self.pool)
2635 .await
2636 .map_err(|e| anyhow::anyhow!("Failed to get pool snapshot validation state: {e}"))?;
2637
2638 Ok(row.map(|row| row.get::<String, _>("validation_state")))
2639 }
2640
2641 pub async fn load_pool_positions_for_snapshot(
2647 &self,
2648 chain_id: u32,
2649 pool_identifier: &PoolIdentifier,
2650 snapshot_block: u64,
2651 snapshot_transaction_index: u32,
2652 snapshot_log_index: u32,
2653 ) -> anyhow::Result<Vec<PoolPosition>> {
2654 let rows = sqlx::query(
2655 "
2656 SELECT
2657 owner, tick_lower, tick_upper,
2658 liquidity::TEXT, fee_growth_inside_0_last::TEXT, fee_growth_inside_1_last::TEXT,
2659 tokens_owed_0::TEXT, tokens_owed_1::TEXT,
2660 total_amount0_deposited::TEXT, total_amount1_deposited::TEXT,
2661 total_amount0_collected::TEXT, total_amount1_collected::TEXT
2662 FROM pool_position
2663 WHERE chain_id = $1
2664 AND pool_identifier = $2
2665 AND snapshot_block = $3
2666 AND snapshot_transaction_index = $4
2667 AND snapshot_log_index = $5
2668 ",
2669 )
2670 .bind(chain_id as i32)
2671 .bind(pool_identifier.as_ref())
2672 .bind(snapshot_block as i64)
2673 .bind(snapshot_transaction_index as i32)
2674 .bind(snapshot_log_index as i32)
2675 .fetch_all(&self.pool)
2676 .await
2677 .map_err(|e| anyhow::anyhow!("Failed to load pool positions: {e}"))?;
2678
2679 rows.iter()
2680 .map(|row| {
2681 let owner: String = row.get("owner");
2682 let position = PoolPosition {
2683 owner: validate_address(&owner)?,
2684 tick_lower: row.get("tick_lower"),
2685 tick_upper: row.get("tick_upper"),
2686 liquidity: row.get::<String, _>("liquidity").parse()?,
2687 fee_growth_inside_0_last: row
2688 .get::<String, _>("fee_growth_inside_0_last")
2689 .parse()?,
2690 fee_growth_inside_1_last: row
2691 .get::<String, _>("fee_growth_inside_1_last")
2692 .parse()?,
2693 tokens_owed_0: row.get::<String, _>("tokens_owed_0").parse()?,
2694 tokens_owed_1: row.get::<String, _>("tokens_owed_1").parse()?,
2695 total_amount0_deposited: row
2696 .get::<String, _>("total_amount0_deposited")
2697 .parse()?,
2698 total_amount1_deposited: row
2699 .get::<String, _>("total_amount1_deposited")
2700 .parse()?,
2701 total_amount0_collected: row
2702 .get::<String, _>("total_amount0_collected")
2703 .parse()?,
2704 total_amount1_collected: row
2705 .get::<String, _>("total_amount1_collected")
2706 .parse()?,
2707 };
2708 Ok(position)
2709 })
2710 .collect()
2711 }
2712
2713 pub async fn load_pool_ticks_for_snapshot(
2719 &self,
2720 chain_id: u32,
2721 pool_identifier: &PoolIdentifier,
2722 snapshot_block: u64,
2723 snapshot_transaction_index: u32,
2724 snapshot_log_index: u32,
2725 ) -> anyhow::Result<Vec<PoolTick>> {
2726 let rows = sqlx::query(
2727 "
2728 SELECT
2729 tick_value, liquidity_gross::TEXT, liquidity_net::TEXT,
2730 fee_growth_outside_0::TEXT, fee_growth_outside_1::TEXT, initialized,
2731 last_updated_block
2732 FROM pool_tick
2733 WHERE chain_id = $1
2734 AND pool_identifier = $2
2735 AND snapshot_block = $3
2736 AND snapshot_transaction_index = $4
2737 AND snapshot_log_index = $5
2738 ",
2739 )
2740 .bind(chain_id as i32)
2741 .bind(pool_identifier.as_ref())
2742 .bind(snapshot_block as i64)
2743 .bind(snapshot_transaction_index as i32)
2744 .bind(snapshot_log_index as i32)
2745 .fetch_all(&self.pool)
2746 .await
2747 .map_err(|e| anyhow::anyhow!("Failed to load pool ticks: {e}"))?;
2748
2749 rows.iter()
2750 .map(|row| {
2751 let tick = PoolTick::new(
2752 row.get("tick_value"),
2753 row.get::<String, _>("liquidity_gross").parse()?,
2754 row.get::<String, _>("liquidity_net").parse()?,
2755 row.get::<String, _>("fee_growth_outside_0").parse()?,
2756 row.get::<String, _>("fee_growth_outside_1").parse()?,
2757 row.get("initialized"),
2758 u64::try_from(row.get::<i64, _>("last_updated_block"))
2759 .with_context(|| "Pool tick last updated block is negative")?,
2760 );
2761 Ok(tick)
2762 })
2763 .collect()
2764 }
2765
2766 pub fn stream_pool_events<'a>(
2780 &'a self,
2781 chain: SharedChain,
2782 dex: SharedDex,
2783 instrument_id: InstrumentId,
2784 pool_identifier: PoolIdentifier,
2785 from_position: Option<BlockPosition>,
2786 to_block: Option<u64>,
2787 ) -> Pin<Box<dyn Stream<Item = Result<DexPoolData, anyhow::Error>> + Send + 'a>> {
2788 const QUERY_ALL: &str = "
2789 SELECT events.*,
2790 COALESCE(
2791 (SELECT hash FROM pool_event_block WHERE pool_event_block.chain_id = events.chain_id AND pool_event_block.number = events.block),
2792 (SELECT hash FROM block WHERE block.chain_id = events.chain_id AND block.number = events.block)
2793 ) as block_hash
2794 FROM (
2795 (SELECT
2796 'swap' as event_type,
2797 chain_id,
2798 pool_identifier,
2799 block,
2800 transaction_hash,
2801 transaction_index,
2802 log_index,
2803 COALESCE(
2804 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_swap_event.chain_id AND block.number = pool_swap_event.block),
2805 (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)
2806 ) as block_timestamp,
2807 sender,
2808 recipient,
2809 NULL::TEXT as owner,
2810 sqrt_price_x96::TEXT,
2811 liquidity::TEXT as swap_liquidity,
2812 tick as swap_tick,
2813 amount0::TEXT as swap_amount0,
2814 amount1::TEXT as swap_amount1,
2815 NULL::TEXT as position_liquidity,
2816 NULL::TEXT as amount0,
2817 NULL::TEXT as amount1,
2818 NULL::INT as tick_lower,
2819 NULL::INT as tick_upper,
2820 NULL::TEXT as liquidity_event_type,
2821 NULL::TEXT as flash_amount0,
2822 NULL::TEXT as flash_amount1,
2823 NULL::TEXT as flash_paid0,
2824 NULL::TEXT as flash_paid1,
2825 NULL::INTEGER as fee_protocol0_new,
2826 NULL::INTEGER as fee_protocol1_new
2827 FROM pool_swap_event
2828 WHERE chain_id = $1 AND pool_identifier = $2
2829 AND ($3::BIGINT IS NULL OR block <= $3))
2830 UNION ALL
2831 (SELECT
2832 'liquidity' as event_type,
2833 chain_id,
2834 pool_identifier,
2835 block,
2836 transaction_hash,
2837 transaction_index,
2838 log_index,
2839 COALESCE(
2840 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_liquidity_event.chain_id AND block.number = pool_liquidity_event.block),
2841 (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)
2842 ) as block_timestamp,
2843 sender,
2844 NULL::TEXT as recipient,
2845 owner,
2846 NULL::text as sqrt_price_x96,
2847 NULL::TEXT as swap_liquidity,
2848 NULL::INT as swap_tick,
2849 amount0::TEXT as swap_amount0,
2850 amount1::TEXT as swap_amount1,
2851 position_liquidity::TEXT,
2852 amount0::TEXT,
2853 amount1::TEXT,
2854 tick_lower::INT,
2855 tick_upper::INT,
2856 event_type as liquidity_event_type,
2857 NULL::TEXT as flash_amount0,
2858 NULL::TEXT as flash_amount1,
2859 NULL::TEXT as flash_paid0,
2860 NULL::TEXT as flash_paid1,
2861 NULL::INTEGER as fee_protocol0_new,
2862 NULL::INTEGER as fee_protocol1_new
2863 FROM pool_liquidity_event
2864 WHERE chain_id = $1 AND pool_identifier = $2
2865 AND ($3::BIGINT IS NULL OR block <= $3))
2866 UNION ALL
2867 (SELECT
2868 'collect' as event_type,
2869 chain_id,
2870 pool_identifier,
2871 block,
2872 transaction_hash,
2873 transaction_index,
2874 log_index,
2875 COALESCE(
2876 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_collect_event.chain_id AND block.number = pool_collect_event.block),
2877 (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)
2878 ) as block_timestamp,
2879 NULL::TEXT as sender,
2880 NULL::TEXT as recipient,
2881 owner,
2882 NULL::TEXT as sqrt_price_x96,
2883 NULL::TEXT as swap_liquidity,
2884 NULL::INT AS swap_tick,
2885 amount0::TEXT as swap_amount0,
2886 amount1::TEXT as swap_amount1,
2887 NULL::TEXT as position_liquidity,
2888 amount0::TEXT,
2889 amount1::TEXT,
2890 tick_lower::INT,
2891 tick_upper::INT,
2892 NULL::TEXT as liquidity_event_type,
2893 NULL::TEXT as flash_amount0,
2894 NULL::TEXT as flash_amount1,
2895 NULL::TEXT as flash_paid0,
2896 NULL::TEXT as flash_paid1,
2897 NULL::INTEGER as fee_protocol0_new,
2898 NULL::INTEGER as fee_protocol1_new
2899 FROM pool_collect_event
2900 WHERE chain_id = $1 AND pool_identifier = $2
2901 AND ($3::BIGINT IS NULL OR block <= $3))
2902 UNION ALL
2903 (SELECT
2904 'fee_protocol_update' as event_type,
2905 chain_id,
2906 pool_identifier,
2907 block,
2908 transaction_hash,
2909 transaction_index,
2910 log_index,
2911 COALESCE(
2912 (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),
2913 (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)
2914 ) as block_timestamp,
2915 NULL::TEXT as sender,
2916 NULL::TEXT as recipient,
2917 NULL::TEXT as owner,
2918 NULL::TEXT as sqrt_price_x96,
2919 NULL::TEXT as swap_liquidity,
2920 NULL::INT AS swap_tick,
2921 NULL::TEXT as swap_amount0,
2922 NULL::TEXT as swap_amount1,
2923 NULL::TEXT as position_liquidity,
2924 NULL::TEXT as amount0,
2925 NULL::TEXT as amount1,
2926 NULL::INT as tick_lower,
2927 NULL::INT as tick_upper,
2928 NULL::TEXT as liquidity_event_type,
2929 NULL::TEXT as flash_amount0,
2930 NULL::TEXT as flash_amount1,
2931 NULL::TEXT as flash_paid0,
2932 NULL::TEXT as flash_paid1,
2933 fee_protocol0_new::INTEGER,
2934 fee_protocol1_new::INTEGER
2935 FROM pool_fee_protocol_update_event
2936 WHERE chain_id = $1 AND pool_identifier = $2
2937 AND ($3::BIGINT IS NULL OR block <= $3))
2938 UNION ALL
2939 (SELECT
2940 'fee_protocol_collect' as event_type,
2941 chain_id,
2942 pool_identifier,
2943 block,
2944 transaction_hash,
2945 transaction_index,
2946 log_index,
2947 COALESCE(
2948 (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),
2949 (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)
2950 ) as block_timestamp,
2951 sender,
2952 recipient,
2953 NULL::TEXT as owner,
2954 NULL::TEXT as sqrt_price_x96,
2955 NULL::TEXT as swap_liquidity,
2956 NULL::INT AS swap_tick,
2957 NULL::TEXT as swap_amount0,
2958 NULL::TEXT as swap_amount1,
2959 NULL::TEXT as position_liquidity,
2960 amount0::TEXT,
2961 amount1::TEXT,
2962 NULL::INT as tick_lower,
2963 NULL::INT as tick_upper,
2964 NULL::TEXT as liquidity_event_type,
2965 NULL::TEXT as flash_amount0,
2966 NULL::TEXT as flash_amount1,
2967 NULL::TEXT as flash_paid0,
2968 NULL::TEXT as flash_paid1,
2969 NULL::INTEGER as fee_protocol0_new,
2970 NULL::INTEGER as fee_protocol1_new
2971 FROM pool_fee_protocol_collect_event
2972 WHERE chain_id = $1 AND pool_identifier = $2
2973 AND ($3::BIGINT IS NULL OR block <= $3))
2974 UNION ALL
2975 (SELECT
2976 'flash' as event_type,
2977 chain_id,
2978 pool_identifier,
2979 block,
2980 transaction_hash,
2981 transaction_index,
2982 log_index,
2983 COALESCE(
2984 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_flash_event.chain_id AND block.number = pool_flash_event.block),
2985 (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)
2986 ) as block_timestamp,
2987 sender,
2988 recipient,
2989 NULL::TEXT as owner,
2990 NULL::TEXT as sqrt_price_x96,
2991 NULL::TEXT as swap_liquidity,
2992 NULL::INT AS swap_tick,
2993 NULL::TEXT as swap_amount0,
2994 NULL::TEXT as swap_amount1,
2995 NULL::TEXT as position_liquidity,
2996 NULL::TEXT as amount0,
2997 NULL::TEXT as amount1,
2998 NULL::INT as tick_lower,
2999 NULL::INT as tick_upper,
3000 NULL::TEXT as liquidity_event_type,
3001 amount0::TEXT as flash_amount0,
3002 amount1::TEXT as flash_amount1,
3003 paid0::TEXT as flash_paid0,
3004 paid1::TEXT as flash_paid1,
3005 NULL::INTEGER as fee_protocol0_new,
3006 NULL::INTEGER as fee_protocol1_new
3007 FROM pool_flash_event
3008 WHERE chain_id = $1 AND pool_identifier = $2
3009 AND ($3::BIGINT IS NULL OR block <= $3))
3010 ) AS events
3011 ORDER BY events.block, events.transaction_index, events.log_index";
3012
3013 const QUERY_FROM_POSITION: &str = "
3014 SELECT events.*,
3015 COALESCE(
3016 (SELECT hash FROM pool_event_block WHERE pool_event_block.chain_id = events.chain_id AND pool_event_block.number = events.block),
3017 (SELECT hash FROM block WHERE block.chain_id = events.chain_id AND block.number = events.block)
3018 ) as block_hash
3019 FROM (
3020 (SELECT
3021 'swap' as event_type,
3022 chain_id,
3023 pool_identifier,
3024 block,
3025 transaction_hash,
3026 transaction_index,
3027 log_index,
3028 COALESCE(
3029 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_swap_event.chain_id AND block.number = pool_swap_event.block),
3030 (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)
3031 ) as block_timestamp,
3032 sender,
3033 recipient,
3034 NULL::TEXT as owner,
3035 sqrt_price_x96::TEXT,
3036 liquidity::TEXT as swap_liquidity,
3037 tick as swap_tick,
3038 amount0::TEXT as swap_amount0,
3039 amount1::TEXT as swap_amount1,
3040 NULL::TEXT as position_liquidity,
3041 NULL::TEXT as amount0,
3042 NULL::TEXT as amount1,
3043 NULL::INT as tick_lower,
3044 NULL::INT as tick_upper,
3045 NULL::TEXT as liquidity_event_type,
3046 NULL::TEXT as flash_amount0,
3047 NULL::TEXT as flash_amount1,
3048 NULL::TEXT as flash_paid0,
3049 NULL::TEXT as flash_paid1,
3050 NULL::INTEGER as fee_protocol0_new,
3051 NULL::INTEGER as fee_protocol1_new
3052 FROM pool_swap_event
3053 WHERE chain_id = $1 AND pool_identifier = $2
3054 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3055 AND ($6::BIGINT IS NULL OR block <= $6))
3056 UNION ALL
3057 (SELECT
3058 'liquidity' as event_type,
3059 chain_id,
3060 pool_identifier,
3061 block,
3062 transaction_hash,
3063 transaction_index,
3064 log_index,
3065 COALESCE(
3066 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_liquidity_event.chain_id AND block.number = pool_liquidity_event.block),
3067 (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)
3068 ) as block_timestamp,
3069 sender,
3070 NULL::TEXT as recipient,
3071 owner,
3072 NULL::text as sqrt_price_x96,
3073 NULL::TEXT as swap_liquidity,
3074 NULL::INT as swap_tick,
3075 amount0::TEXT as swap_amount0,
3076 amount1::TEXT as swap_amount1,
3077 position_liquidity::TEXT,
3078 amount0::TEXT,
3079 amount1::TEXT,
3080 tick_lower::INT,
3081 tick_upper::INT,
3082 event_type as liquidity_event_type,
3083 NULL::TEXT as flash_amount0,
3084 NULL::TEXT as flash_amount1,
3085 NULL::TEXT as flash_paid0,
3086 NULL::TEXT as flash_paid1,
3087 NULL::INTEGER as fee_protocol0_new,
3088 NULL::INTEGER as fee_protocol1_new
3089 FROM pool_liquidity_event
3090 WHERE chain_id = $1 AND pool_identifier = $2
3091 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3092 AND ($6::BIGINT IS NULL OR block <= $6))
3093 UNION ALL
3094 (SELECT
3095 'collect' as event_type,
3096 chain_id,
3097 pool_identifier,
3098 block,
3099 transaction_hash,
3100 transaction_index,
3101 log_index,
3102 COALESCE(
3103 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_collect_event.chain_id AND block.number = pool_collect_event.block),
3104 (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)
3105 ) as block_timestamp,
3106 NULL::TEXT as sender,
3107 NULL::TEXT as recipient,
3108 owner,
3109 NULL::TEXT as sqrt_price_x96,
3110 NULL::TEXT as swap_liquidity,
3111 NULL::INT AS swap_tick,
3112 amount0::TEXT as swap_amount0,
3113 amount1::TEXT as swap_amount1,
3114 NULL::TEXT as position_liquidity,
3115 amount0::TEXT,
3116 amount1::TEXT,
3117 tick_lower::INT,
3118 tick_upper::INT,
3119 NULL::TEXT as liquidity_event_type,
3120 NULL::TEXT as flash_amount0,
3121 NULL::TEXT as flash_amount1,
3122 NULL::TEXT as flash_paid0,
3123 NULL::TEXT as flash_paid1,
3124 NULL::INTEGER as fee_protocol0_new,
3125 NULL::INTEGER as fee_protocol1_new
3126 FROM pool_collect_event
3127 WHERE chain_id = $1 AND pool_identifier = $2
3128 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3129 AND ($6::BIGINT IS NULL OR block <= $6))
3130 UNION ALL
3131 (SELECT
3132 'fee_protocol_update' as event_type,
3133 chain_id,
3134 pool_identifier,
3135 block,
3136 transaction_hash,
3137 transaction_index,
3138 log_index,
3139 COALESCE(
3140 (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),
3141 (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)
3142 ) as block_timestamp,
3143 NULL::TEXT as sender,
3144 NULL::TEXT as recipient,
3145 NULL::TEXT as owner,
3146 NULL::TEXT as sqrt_price_x96,
3147 NULL::TEXT as swap_liquidity,
3148 NULL::INT AS swap_tick,
3149 NULL::TEXT as swap_amount0,
3150 NULL::TEXT as swap_amount1,
3151 NULL::TEXT as position_liquidity,
3152 NULL::TEXT as amount0,
3153 NULL::TEXT as amount1,
3154 NULL::INT as tick_lower,
3155 NULL::INT as tick_upper,
3156 NULL::TEXT as liquidity_event_type,
3157 NULL::TEXT as flash_amount0,
3158 NULL::TEXT as flash_amount1,
3159 NULL::TEXT as flash_paid0,
3160 NULL::TEXT as flash_paid1,
3161 fee_protocol0_new::INTEGER,
3162 fee_protocol1_new::INTEGER
3163 FROM pool_fee_protocol_update_event
3164 WHERE chain_id = $1 AND pool_identifier = $2
3165 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3166 AND ($6::BIGINT IS NULL OR block <= $6))
3167 UNION ALL
3168 (SELECT
3169 'fee_protocol_collect' as event_type,
3170 chain_id,
3171 pool_identifier,
3172 block,
3173 transaction_hash,
3174 transaction_index,
3175 log_index,
3176 COALESCE(
3177 (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),
3178 (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)
3179 ) as block_timestamp,
3180 sender,
3181 recipient,
3182 NULL::TEXT as owner,
3183 NULL::TEXT as sqrt_price_x96,
3184 NULL::TEXT as swap_liquidity,
3185 NULL::INT AS swap_tick,
3186 NULL::TEXT as swap_amount0,
3187 NULL::TEXT as swap_amount1,
3188 NULL::TEXT as position_liquidity,
3189 amount0::TEXT,
3190 amount1::TEXT,
3191 NULL::INT as tick_lower,
3192 NULL::INT as tick_upper,
3193 NULL::TEXT as liquidity_event_type,
3194 NULL::TEXT as flash_amount0,
3195 NULL::TEXT as flash_amount1,
3196 NULL::TEXT as flash_paid0,
3197 NULL::TEXT as flash_paid1,
3198 NULL::INTEGER as fee_protocol0_new,
3199 NULL::INTEGER as fee_protocol1_new
3200 FROM pool_fee_protocol_collect_event
3201 WHERE chain_id = $1 AND pool_identifier = $2
3202 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3203 AND ($6::BIGINT IS NULL OR block <= $6))
3204 UNION ALL
3205 (SELECT
3206 'flash' as event_type,
3207 chain_id,
3208 pool_identifier,
3209 block,
3210 transaction_hash,
3211 transaction_index,
3212 log_index,
3213 COALESCE(
3214 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_flash_event.chain_id AND block.number = pool_flash_event.block),
3215 (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)
3216 ) as block_timestamp,
3217 sender,
3218 recipient,
3219 NULL::TEXT as owner,
3220 NULL::TEXT as sqrt_price_x96,
3221 NULL::TEXT as swap_liquidity,
3222 NULL::INT AS swap_tick,
3223 NULL::TEXT as swap_amount0,
3224 NULL::TEXT as swap_amount1,
3225 NULL::TEXT as position_liquidity,
3226 NULL::TEXT as amount0,
3227 NULL::TEXT as amount1,
3228 NULL::INT as tick_lower,
3229 NULL::INT as tick_upper,
3230 NULL::TEXT as liquidity_event_type,
3231 amount0::TEXT as flash_amount0,
3232 amount1::TEXT as flash_amount1,
3233 paid0::TEXT as flash_paid0,
3234 paid1::TEXT as flash_paid1,
3235 NULL::INTEGER as fee_protocol0_new,
3236 NULL::INTEGER as fee_protocol1_new
3237 FROM pool_flash_event
3238 WHERE chain_id = $1 AND pool_identifier = $2
3239 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3240 AND ($6::BIGINT IS NULL OR block <= $6))
3241 ) AS events
3242 ORDER BY events.block, events.transaction_index, events.log_index";
3243
3244 let query = if let Some(pos) = from_position {
3246 sqlx::query(QUERY_FROM_POSITION)
3247 .bind(chain.chain_id as i32)
3248 .bind(pool_identifier.to_string())
3249 .bind(pos.number as i64)
3250 .bind(pos.transaction_index as i32)
3251 .bind(pos.log_index as i32)
3252 .bind(to_block.map(|block| block as i64))
3253 .fetch(&self.pool)
3254 } else {
3255 sqlx::query(QUERY_ALL)
3256 .bind(chain.chain_id as i32)
3257 .bind(pool_identifier.to_string())
3258 .bind(to_block.map(|block| block as i64))
3259 .fetch(&self.pool)
3260 };
3261
3262 let stream = query.map(move |row_result| match row_result {
3264 Ok(row) => transform_row_to_dex_pool_data(
3265 &row,
3266 Arc::clone(&chain),
3267 Arc::clone(&dex),
3268 instrument_id,
3269 )
3270 .map_err(|e| anyhow::anyhow!("Steam pool event transform error: {e}")),
3271 Err(e) => Err(anyhow::anyhow!("Stream pool events database error: {e}")),
3272 });
3273
3274 Box::pin(stream)
3275 }
3276
3277 #[expect(
3289 clippy::too_many_arguments,
3290 reason = "the parameters mirror the persisted execution transaction fields"
3291 )]
3292 pub async fn add_execution_transaction(
3293 &self,
3294 chain_id: u32,
3295 wallet_address: &str,
3296 nonce: u64,
3297 transaction_hash: &str,
3298 purpose: &str,
3299 status: &str,
3300 client_order_id: Option<&str>,
3301 ) -> anyhow::Result<()> {
3302 let result = sqlx::query(
3303 "
3304 INSERT INTO execution_transaction (
3305 chain_id,
3306 wallet_address,
3307 nonce,
3308 transaction_hash,
3309 purpose,
3310 status,
3311 client_order_id
3312 )
3313 VALUES ($1, $2, $3, $4, $5, $6, $7)
3314 ON CONFLICT (chain_id, transaction_hash)
3315 DO UPDATE SET transaction_hash = EXCLUDED.transaction_hash
3316 WHERE execution_transaction.wallet_address = EXCLUDED.wallet_address
3317 AND execution_transaction.nonce = EXCLUDED.nonce
3318 AND execution_transaction.purpose = EXCLUDED.purpose
3319 AND execution_transaction.status = EXCLUDED.status
3320 AND execution_transaction.client_order_id IS NOT DISTINCT FROM EXCLUDED.client_order_id
3321 ",
3322 )
3323 .bind(chain_id as i32)
3324 .bind(wallet_address)
3325 .bind(nonce as i64)
3326 .bind(transaction_hash)
3327 .bind(purpose)
3328 .bind(status)
3329 .bind(client_order_id)
3330 .execute(&self.pool)
3331 .await
3332 .map_err(|e| anyhow::anyhow!("Failed to insert into execution_transaction table: {e}"))?;
3333
3334 anyhow::ensure!(
3335 result.rows_affected() == 1,
3336 "Execution transaction {transaction_hash} conflicts with its persisted record"
3337 );
3338 Ok(())
3339 }
3340
3341 pub async fn ensure_execution_transaction_schema(&self) -> anyhow::Result<()> {
3352 let mut transaction = self
3353 .pool
3354 .begin()
3355 .await
3356 .map_err(|e| anyhow::anyhow!("Failed to start execution schema migration: {e}"))?;
3357
3358 sqlx::query("LOCK TABLE execution_transaction IN ACCESS EXCLUSIVE MODE")
3359 .execute(&mut *transaction)
3360 .await
3361 .map_err(|e| anyhow::anyhow!("Failed to lock legacy execution transactions: {e}"))?;
3362
3363 for statement in [
3364 "
3365 ALTER TABLE execution_transaction
3366 ADD COLUMN IF NOT EXISTS client_order_id TEXT
3367 ",
3368 "
3369 ALTER TABLE execution_transaction
3370 ADD COLUMN IF NOT EXISTS wallet_address TEXT
3371 ",
3372 "
3373 ALTER TABLE execution_transaction
3374 ALTER COLUMN wallet_address DROP NOT NULL
3375 ",
3376 "
3377 CREATE TABLE IF NOT EXISTS execution_schema_version (
3378 component TEXT PRIMARY KEY,
3379 version SMALLINT NOT NULL CHECK (version > 0)
3380 )
3381 ",
3382 "
3383 CREATE TABLE IF NOT EXISTS execution_intent (
3384 id BIGSERIAL PRIMARY KEY,
3385 schema_version SMALLINT NOT NULL,
3386 chain_id INTEGER NOT NULL REFERENCES chain(chain_id) ON DELETE RESTRICT,
3387 wallet_address TEXT NOT NULL,
3388 nonce BIGINT,
3389 purpose TEXT NOT NULL,
3390 status TEXT NOT NULL,
3391 client_order_id TEXT,
3392 trader_id TEXT,
3393 strategy_id TEXT,
3394 account_id TEXT,
3395 instrument_id TEXT,
3396 pool_address TEXT,
3397 transaction_to TEXT NOT NULL,
3398 transaction_input TEXT NOT NULL,
3399 transaction_value TEXT NOT NULL,
3400 amount_in TEXT,
3401 created_block BIGINT NOT NULL,
3402 acknowledgement_emitted BOOLEAN NOT NULL DEFAULT FALSE,
3403 fill_emitted BOOLEAN NOT NULL DEFAULT FALSE,
3404 terminal_emitted BOOLEAN NOT NULL DEFAULT FALSE,
3405 active BOOLEAN NOT NULL DEFAULT TRUE,
3406 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3407 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3408 CHECK (schema_version = 2),
3409 CHECK (nonce IS NULL OR nonce >= 0),
3410 CHECK (created_block >= 0),
3411 CHECK (status IN (
3412 'prepared', 'signed', 'broadcast', 'included', 'finalized',
3413 'reverted', 'replaced', 'dropped', 'reorged', 'recoverable'
3414 )),
3415 CONSTRAINT execution_intent_active_check CHECK (
3416 NOT active
3417 OR status NOT IN ('finalized', 'reverted', 'recoverable')
3418 OR (
3419 status IN ('finalized', 'reverted')
3420 AND NOT (fill_emitted OR terminal_emitted)
3421 )
3422 ),
3423 CHECK (NOT (fill_emitted AND terminal_emitted)),
3424 CHECK (
3425 purpose <> 'swap'
3426 OR (
3427 client_order_id IS NOT NULL
3428 AND trader_id IS NOT NULL
3429 AND strategy_id IS NOT NULL
3430 AND account_id IS NOT NULL
3431 AND instrument_id IS NOT NULL
3432 AND pool_address IS NOT NULL
3433 AND amount_in IS NOT NULL
3434 )
3435 ),
3436 UNIQUE (id, chain_id)
3437 )
3438 ",
3439 "
3440 CREATE UNIQUE INDEX IF NOT EXISTS execution_intent_active_signer_key
3441 ON execution_intent (chain_id, wallet_address)
3442 WHERE active
3443 ",
3444 "
3445 CREATE UNIQUE INDEX IF NOT EXISTS execution_intent_active_nonce_key
3446 ON execution_intent (chain_id, wallet_address, nonce)
3447 WHERE active AND nonce IS NOT NULL
3448 ",
3449 "
3450 CREATE UNIQUE INDEX IF NOT EXISTS execution_intent_client_order_key
3451 ON execution_intent (chain_id, wallet_address, client_order_id)
3452 WHERE client_order_id IS NOT NULL
3453 ",
3454 "
3455 ALTER TABLE execution_intent
3456 DROP CONSTRAINT IF EXISTS execution_intent_active_check
3457 ",
3458 "
3459 ALTER TABLE execution_intent
3460 ADD CONSTRAINT execution_intent_active_check CHECK (
3461 NOT active
3462 OR status NOT IN ('finalized', 'reverted', 'recoverable')
3463 OR (
3464 status IN ('finalized', 'reverted')
3465 AND NOT (fill_emitted OR terminal_emitted)
3466 )
3467 )
3468 ",
3469 "
3470 CREATE TABLE IF NOT EXISTS execution_transaction_hash (
3471 id BIGSERIAL PRIMARY KEY,
3472 intent_id BIGINT NOT NULL,
3473 chain_id INTEGER NOT NULL,
3474 transaction_hash TEXT NOT NULL,
3475 payload_expected BOOLEAN NOT NULL DEFAULT TRUE,
3476 raw_transaction BYTEA,
3477 sealed_transaction BYTEA,
3478 status TEXT NOT NULL,
3479 block_number BIGINT,
3480 block_hash TEXT,
3481 receipt_success BOOLEAN,
3482 gas_used BIGINT,
3483 effective_gas_price TEXT,
3484 current BOOLEAN NOT NULL DEFAULT TRUE,
3485 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3486 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3487 FOREIGN KEY (intent_id, chain_id)
3488 REFERENCES execution_intent(id, chain_id) ON DELETE RESTRICT,
3489 CHECK (block_number IS NULL OR block_number >= 0),
3490 CHECK (gas_used IS NULL OR gas_used >= 0),
3491 CONSTRAINT execution_transaction_raw_size_check
3492 CHECK (raw_transaction IS NULL OR octet_length(raw_transaction) <= 131072),
3493 CONSTRAINT execution_transaction_sealed_size_check
3494 CHECK (sealed_transaction IS NULL OR octet_length(sealed_transaction) <= 131133),
3495 CHECK (status IN (
3496 'signed', 'broadcast', 'included', 'finalized', 'reverted',
3497 'replaced', 'dropped', 'reorged'
3498 )),
3499 UNIQUE (chain_id, transaction_hash),
3500 UNIQUE (intent_id, transaction_hash)
3501 )
3502 ",
3503 "
3504 ALTER TABLE execution_transaction_hash
3505 ADD COLUMN IF NOT EXISTS payload_expected BOOLEAN
3506 ",
3507 "
3508 UPDATE execution_transaction_hash
3509 SET payload_expected = raw_transaction IS NOT NULL
3510 WHERE payload_expected IS NULL
3511 ",
3512 "
3513 ALTER TABLE execution_transaction_hash
3514 ALTER COLUMN payload_expected SET DEFAULT TRUE
3515 ",
3516 "
3517 ALTER TABLE execution_transaction_hash
3518 ALTER COLUMN payload_expected SET NOT NULL
3519 ",
3520 "
3521 ALTER TABLE execution_transaction_hash
3522 ADD COLUMN IF NOT EXISTS sealed_transaction BYTEA
3523 ",
3524 "
3525 ALTER TABLE execution_transaction_hash
3526 DROP CONSTRAINT IF EXISTS execution_transaction_raw_size_check
3527 ",
3528 "
3529 ALTER TABLE execution_transaction_hash
3530 ADD CONSTRAINT execution_transaction_raw_size_check
3531 CHECK (raw_transaction IS NULL OR octet_length(raw_transaction) <= 131072)
3532 ",
3533 "
3534 ALTER TABLE execution_transaction_hash
3535 DROP CONSTRAINT IF EXISTS execution_transaction_sealed_size_check
3536 ",
3537 "
3538 ALTER TABLE execution_transaction_hash
3539 ADD CONSTRAINT execution_transaction_sealed_size_check
3540 CHECK (sealed_transaction IS NULL OR octet_length(sealed_transaction) <= 131133)
3541 ",
3542 "
3543 CREATE TABLE IF NOT EXISTS execution_payload_state (
3544 component TEXT PRIMARY KEY CHECK (component = 'signed_transactions'),
3545 deployment_id TEXT NOT NULL CHECK (deployment_id <> ''),
3546 protocol_version SMALLINT NOT NULL CHECK (protocol_version = 1),
3547 operation TEXT NOT NULL CHECK (operation IN ('migrate', 'ready', 'rewrap', 'rollback')),
3548 active_key_id BYTEA NOT NULL CHECK (octet_length(active_key_id) = 32),
3549 progress_id BIGINT NOT NULL DEFAULT 0 CHECK (progress_id >= 0),
3550 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
3551 )
3552 ",
3553 "
3554 CREATE TABLE IF NOT EXISTS execution_payload_key_state (
3555 key_id BYTEA PRIMARY KEY CHECK (octet_length(key_id) = 32),
3556 seals BIGINT NOT NULL DEFAULT 0 CHECK (seals >= 0 AND seals < 4294967296)
3557 )
3558 ",
3559 "
3560 CREATE UNIQUE INDEX IF NOT EXISTS execution_transaction_hash_current_key
3561 ON execution_transaction_hash (intent_id)
3562 WHERE current
3563 ",
3564 "
3565 CREATE TABLE IF NOT EXISTS execution_transaction_transition (
3566 id BIGSERIAL PRIMARY KEY,
3567 intent_id BIGINT NOT NULL REFERENCES execution_intent(id) ON DELETE RESTRICT,
3568 transaction_hash_id BIGINT REFERENCES execution_transaction_hash(id) ON DELETE RESTRICT,
3569 transition_key TEXT NOT NULL,
3570 from_status TEXT,
3571 to_status TEXT NOT NULL,
3572 block_number BIGINT,
3573 block_hash TEXT,
3574 observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3575 CHECK (block_number IS NULL OR block_number >= 0),
3576 CHECK (to_status IN (
3577 'prepared', 'signed', 'broadcast', 'included', 'finalized',
3578 'reverted', 'replaced', 'dropped', 'reorged', 'recoverable'
3579 )),
3580 UNIQUE (intent_id, transition_key)
3581 )
3582 ",
3583 ] {
3584 sqlx::query(statement)
3585 .execute(&mut *transaction)
3586 .await
3587 .map_err(|e| {
3588 anyhow::anyhow!("Failed to migrate execution_transaction table: {e}")
3589 })?;
3590 }
3591
3592 let installed_version = sqlx::query_scalar::<_, i16>(
3593 "SELECT version FROM execution_schema_version WHERE component = 'evm_execution'",
3594 )
3595 .fetch_optional(&mut *transaction)
3596 .await
3597 .map_err(|e| anyhow::anyhow!("Failed to read execution schema version: {e}"))?;
3598 if let Some(installed_version) = installed_version
3599 && installed_version > EXECUTION_SCHEMA_VERSION
3600 {
3601 anyhow::bail!(
3602 "Execution schema version {installed_version} is newer than supported version {EXECUTION_SCHEMA_VERSION}"
3603 );
3604 }
3605
3606 let unresolved_legacy = sqlx::query_scalar::<_, i64>(
3607 "
3608 SELECT COUNT(*)
3609 FROM execution_transaction
3610 WHERE status IN ('pending', 'included', 'reverted')
3611 ",
3612 )
3613 .fetch_one(&mut *transaction)
3614 .await
3615 .map_err(|e| anyhow::anyhow!("Failed to inspect legacy execution transactions: {e}"))?;
3616 anyhow::ensure!(
3617 unresolved_legacy == 0,
3618 "Cannot safely migrate {unresolved_legacy} unresolved execution schema version 1 transaction(s); resolve them with the prior version before enabling version {EXECUTION_SCHEMA_VERSION}"
3619 );
3620
3621 for statement in [
3622 "
3623 CREATE OR REPLACE FUNCTION execution_transaction_v2_fence()
3624 RETURNS TRIGGER AS $$
3625 BEGIN
3626 RAISE EXCEPTION 'Legacy execution writer refused after schema version 2 activation';
3627 END;
3628 $$ LANGUAGE plpgsql
3629 ",
3630 "DROP TRIGGER IF EXISTS execution_transaction_v2_fence ON execution_transaction",
3631 "
3632 CREATE TRIGGER execution_transaction_v2_fence
3633 BEFORE INSERT OR UPDATE OR DELETE ON execution_transaction
3634 FOR EACH STATEMENT EXECUTE FUNCTION execution_transaction_v2_fence()
3635 ",
3636 "
3637 CREATE OR REPLACE FUNCTION execution_transition_append_only()
3638 RETURNS TRIGGER AS $$
3639 BEGIN
3640 RAISE EXCEPTION 'Execution transitions are append-only';
3641 END;
3642 $$ LANGUAGE plpgsql
3643 ",
3644 "DROP TRIGGER IF EXISTS execution_transition_append_only ON execution_transaction_transition",
3645 "
3646 CREATE TRIGGER execution_transition_append_only
3647 BEFORE UPDATE OR DELETE ON execution_transaction_transition
3648 FOR EACH STATEMENT EXECUTE FUNCTION execution_transition_append_only()
3649 ",
3650 "
3651 INSERT INTO execution_schema_version (component, version)
3652 VALUES ('evm_execution', 2)
3653 ON CONFLICT (component) DO UPDATE SET version = EXCLUDED.version
3654 WHERE execution_schema_version.version <= EXCLUDED.version
3655 ",
3656 ] {
3657 sqlx::query(statement)
3658 .execute(&mut *transaction)
3659 .await
3660 .map_err(|e| {
3661 anyhow::anyhow!("Failed to activate execution schema version 2: {e}")
3662 })?;
3663 }
3664
3665 transaction
3666 .commit()
3667 .await
3668 .map_err(|e| anyhow::anyhow!("Failed to commit execution schema migration: {e}"))?;
3669
3670 Ok(())
3671 }
3672
3673 pub(crate) async fn load_execution_verification_position(
3675 &self,
3676 chain_id: u32,
3677 wallet_address: &str,
3678 manifest_version: &str,
3679 manifest_digest: &str,
3680 ) -> anyhow::Result<Option<ExecutionVerificationPosition>> {
3681 let installed = sqlx::query_scalar::<_, i16>(
3682 "SELECT version FROM execution_schema_version \
3683 WHERE component = 'evm_execution_verification'",
3684 )
3685 .fetch_optional(&self.pool)
3686 .await
3687 .context("failed to inspect execution verification schema")?;
3688 let Some(installed) = installed else {
3689 return Ok(None);
3690 };
3691 anyhow::ensure!(
3692 installed <= VERIFICATION_SCHEMA_VERSION,
3693 "Unsupported execution verification schema version {installed}"
3694 );
3695 let chain_id =
3696 i32::try_from(chain_id).context("Verification chain ID exceeds PostgreSQL INTEGER")?;
3697 let current = sqlx::query_as::<_, (String, String, i64, i64)>(
3698 "
3699 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
3700 FROM execution_verification_nonce
3701 WHERE chain_id = $1 AND wallet_address = $2
3702 ",
3703 )
3704 .bind(chain_id)
3705 .bind(wallet_address)
3706 .fetch_optional(&self.pool)
3707 .await
3708 .context("failed to load execution verification nonce position")?;
3709 let Some((stored_version, stored_digest, nonce, revision)) = current else {
3710 return Ok(None);
3711 };
3712 anyhow::ensure!(
3713 stored_version == manifest_version && stored_digest == manifest_digest,
3714 "Execution verification manifest identity changed"
3715 );
3716 let row = sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
3717 "
3718 SELECT number, hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
3719 FROM execution_verified_finalized_header
3720 WHERE chain_id = $1 AND wallet_address = $2
3721 ORDER BY number DESC
3722 LIMIT 1
3723 ",
3724 )
3725 .bind(chain_id)
3726 .bind(wallet_address)
3727 .fetch_optional(&self.pool)
3728 .await
3729 .context("failed to load verified finalized header tip")?
3730 .ok_or_else(|| anyhow::anyhow!("Verified finalized header ledger is empty"))?;
3731 let (number, hash, parent_hash, timestamp, base_fee, digest) = row;
3732 anyhow::ensure!(
3733 digest == manifest_digest,
3734 "Finalized header manifest identity changed"
3735 );
3736 Ok(Some(ExecutionVerificationPosition {
3737 next_canonical_nonce: u64::try_from(nonce).context("Canonical nonce is negative")?,
3738 revision: u64::try_from(revision).context("Canonical nonce revision is negative")?,
3739 finalized_tip: ExecutionVerifiedHeader {
3740 number: u64::try_from(number).context("Finalized header number is negative")?,
3741 hash,
3742 parent_hash,
3743 timestamp: u64::try_from(timestamp)
3744 .context("Finalized header timestamp is negative")?,
3745 base_fee_per_gas: base_fee
3746 .map(|value| {
3747 value
3748 .parse::<u128>()
3749 .map_err(|_| anyhow::anyhow!("Finalized header base fee is invalid"))
3750 })
3751 .transpose()?,
3752 },
3753 }))
3754 }
3755
3756 pub(crate) async fn load_execution_verified_header(
3757 &self,
3758 chain_id: u32,
3759 wallet_address: &str,
3760 number: u64,
3761 manifest_digest: &str,
3762 ) -> anyhow::Result<Option<ExecutionVerifiedHeader>> {
3763 let chain_id =
3764 i32::try_from(chain_id).context("Verification chain ID exceeds PostgreSQL INTEGER")?;
3765 let number =
3766 i64::try_from(number).context("Finalized header number exceeds PostgreSQL BIGINT")?;
3767 let row = sqlx::query_as::<_, (String, String, i64, Option<String>, String)>(
3768 "
3769 SELECT hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
3770 FROM execution_verified_finalized_header
3771 WHERE chain_id = $1 AND wallet_address = $2 AND number = $3
3772 ",
3773 )
3774 .bind(chain_id)
3775 .bind(wallet_address)
3776 .bind(number)
3777 .fetch_optional(&self.pool)
3778 .await
3779 .context("failed to load verified finalized header")?;
3780 let Some((hash, parent_hash, timestamp, base_fee, digest)) = row else {
3781 return Ok(None);
3782 };
3783 anyhow::ensure!(
3784 digest == manifest_digest,
3785 "Finalized header manifest identity changed"
3786 );
3787 Ok(Some(ExecutionVerifiedHeader {
3788 number: u64::try_from(number).context("Finalized header number is negative")?,
3789 hash,
3790 parent_hash,
3791 timestamp: u64::try_from(timestamp)
3792 .context("Finalized header timestamp is negative")?,
3793 base_fee_per_gas: base_fee
3794 .map(|value| {
3795 value
3796 .parse::<u128>()
3797 .map_err(|_| anyhow::anyhow!("Finalized header base fee is invalid"))
3798 })
3799 .transpose()?,
3800 }))
3801 }
3802
3803 pub(crate) async fn load_execution_verification_migration_snapshot(
3805 &self,
3806 chain_id: u32,
3807 wallet_address: &str,
3808 ) -> anyhow::Result<ExecutionVerificationMigrationSnapshot> {
3809 let chain_id =
3810 i32::try_from(chain_id).context("Verification chain ID exceeds PostgreSQL INTEGER")?;
3811 let intents = sqlx::query_as::<_, ExecutionIntentRow>(
3812 "
3813 SELECT
3814 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
3815 client_order_id, trader_id, strategy_id, account_id, instrument_id,
3816 pool_address, transaction_to, transaction_input, transaction_value,
3817 amount_in, created_block, acknowledgement_emitted, fill_emitted,
3818 terminal_emitted, active
3819 FROM execution_intent
3820 WHERE chain_id = $1 AND wallet_address = $2
3821 ORDER BY id
3822 ",
3823 )
3824 .bind(chain_id)
3825 .bind(wallet_address)
3826 .fetch_all(&self.pool)
3827 .await
3828 .context("failed to load retained execution intents for verification migration")?;
3829 let hashes = sqlx::query_as::<_, ExecutionTransactionHashRow>(
3830 "
3831 SELECT
3832 hash.id, hash.intent_id, hash.chain_id, hash.transaction_hash,
3833 hash.payload_expected, hash.raw_transaction, hash.sealed_transaction,
3834 hash.status, hash.block_number, hash.block_hash, hash.receipt_success,
3835 hash.gas_used, hash.effective_gas_price, hash.current
3836 FROM execution_transaction_hash AS hash
3837 JOIN execution_intent AS intent ON intent.id = hash.intent_id
3838 WHERE intent.chain_id = $1 AND intent.wallet_address = $2
3839 ORDER BY hash.id
3840 ",
3841 )
3842 .bind(chain_id)
3843 .bind(wallet_address)
3844 .fetch_all(&self.pool)
3845 .await
3846 .context("failed to load retained execution hashes for verification migration")?;
3847 Ok(ExecutionVerificationMigrationSnapshot { intents, hashes })
3848 }
3849
3850 pub(crate) async fn ensure_execution_verification_schema(
3862 &self,
3863 bootstrap: &ExecutionVerificationBootstrap<'_>,
3864 ) -> anyhow::Result<()> {
3865 let first_header = bootstrap
3866 .finalized_headers
3867 .first()
3868 .ok_or_else(|| anyhow::anyhow!("Verified finalized header ledger is empty"))?;
3869 anyhow::ensure!(
3870 bootstrap.finalized_headers.windows(2).all(|headers| {
3871 headers[1].number == headers[0].number.saturating_add(1)
3872 && headers[1].parent_hash == headers[0].hash
3873 }),
3874 "Verified finalized headers are not one continuous parent-linked chain"
3875 );
3876 anyhow::ensure!(
3877 bootstrap.provider_ids.len() == 3
3878 && bootstrap.operator_ids.len() == 3
3879 && bootstrap.failure_domain_ids.len() >= 3
3880 && !bootstrap.decisions.is_empty(),
3881 "Connect verification evidence is incomplete"
3882 );
3883 let chain_id = i32::try_from(bootstrap.chain_id)
3884 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
3885 let checkpoint_number = i64::try_from(bootstrap.checkpoint_number)
3886 .context("Verification checkpoint exceeds PostgreSQL BIGINT")?;
3887 let checkpoint_timestamp = i64::try_from(bootstrap.checkpoint_timestamp)
3888 .context("Verification checkpoint timestamp exceeds PostgreSQL BIGINT")?;
3889 let next_canonical_nonce = i64::try_from(bootstrap.next_canonical_nonce)
3890 .context("Canonical nonce exceeds PostgreSQL BIGINT")?;
3891 let observed_canonical_nonce = i64::try_from(bootstrap.observed_canonical_nonce)
3892 .context("Observed canonical nonce exceeds PostgreSQL BIGINT")?;
3893 let base_fee = bootstrap
3894 .checkpoint_base_fee_per_gas
3895 .map(|value| value.to_string());
3896 let mut transaction = self.pool.begin().await.map_err(|e| {
3897 anyhow::anyhow!("Failed to start execution verification migration: {e}")
3898 })?;
3899
3900 for statement in [
3901 "
3902 CREATE TABLE IF NOT EXISTS execution_verification_nonce (
3903 chain_id INTEGER NOT NULL REFERENCES chain(chain_id) ON DELETE RESTRICT,
3904 wallet_address TEXT NOT NULL,
3905 manifest_version TEXT NOT NULL CHECK (manifest_version <> ''),
3906 manifest_digest TEXT NOT NULL CHECK (manifest_digest <> ''),
3907 next_canonical_nonce BIGINT NOT NULL CHECK (next_canonical_nonce >= 0),
3908 revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
3909 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3910 PRIMARY KEY (chain_id, wallet_address)
3911 )
3912 ",
3913 "
3914 CREATE TABLE IF NOT EXISTS execution_verified_finalized_header (
3915 chain_id INTEGER NOT NULL,
3916 wallet_address TEXT NOT NULL,
3917 number BIGINT NOT NULL CHECK (number >= 0),
3918 hash TEXT NOT NULL CHECK (hash <> ''),
3919 parent_hash TEXT NOT NULL CHECK (parent_hash <> ''),
3920 timestamp BIGINT NOT NULL CHECK (timestamp >= 0),
3921 base_fee_per_gas TEXT,
3922 manifest_digest TEXT NOT NULL CHECK (manifest_digest <> ''),
3923 observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3924 PRIMARY KEY (chain_id, wallet_address, number),
3925 UNIQUE (chain_id, wallet_address, hash),
3926 FOREIGN KEY (chain_id, wallet_address)
3927 REFERENCES execution_verification_nonce(chain_id, wallet_address)
3928 ON DELETE RESTRICT
3929 )
3930 ",
3931 "
3932 CREATE TABLE IF NOT EXISTS execution_verification_decision (
3933 id BIGSERIAL PRIMARY KEY,
3934 intent_id BIGINT REFERENCES execution_intent(id) ON DELETE RESTRICT,
3935 nonce BIGINT CHECK (nonce IS NULL OR nonce >= 0),
3936 decision_class TEXT NOT NULL CHECK (decision_class <> ''),
3937 read_class TEXT NOT NULL CHECK (read_class <> ''),
3938 height_start BIGINT CHECK (height_start IS NULL OR height_start >= 0),
3939 height_end BIGINT CHECK (height_end IS NULL OR height_end >= 0),
3940 manifest_version TEXT NOT NULL CHECK (manifest_version <> ''),
3941 manifest_digest TEXT NOT NULL CHECK (manifest_digest <> ''),
3942 provider_ids TEXT[] NOT NULL,
3943 operator_ids TEXT[] NOT NULL,
3944 failure_domain_ids TEXT[] NOT NULL,
3945 response_class TEXT NOT NULL CHECK (response_class <> ''),
3946 normalized_value_digest TEXT,
3947 safe_value JSONB,
3948 nonce_revision BIGINT CHECK (nonce_revision IS NULL OR nonce_revision >= 0),
3949 outcome TEXT NOT NULL CHECK (outcome IN (
3950 'verified', 'disagreement', 'unavailable', 'retryable', 'locally_invalid'
3951 )),
3952 transition_key TEXT,
3953 observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3954 CHECK (cardinality(provider_ids) = 3),
3955 CHECK (cardinality(operator_ids) = 3),
3956 CHECK (cardinality(failure_domain_ids) >= 3),
3957 CHECK (safe_value IS NULL OR pg_column_size(safe_value) <= 16384),
3958 UNIQUE (intent_id, transition_key)
3959 )
3960 ",
3961 "
3962 CREATE TABLE IF NOT EXISTS execution_replacement_scan (
3963 intent_id BIGINT PRIMARY KEY REFERENCES execution_intent(id) ON DELETE RESTRICT,
3964 chain_id INTEGER NOT NULL,
3965 wallet_address TEXT NOT NULL,
3966 nonce BIGINT NOT NULL CHECK (nonce >= 0),
3967 finalized_cursor_number BIGINT NOT NULL CHECK (finalized_cursor_number >= 0),
3968 finalized_cursor_hash TEXT NOT NULL CHECK (finalized_cursor_hash <> ''),
3969 manifest_digest TEXT NOT NULL CHECK (manifest_digest <> ''),
3970 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3971 UNIQUE (chain_id, wallet_address, nonce)
3972 )
3973 ",
3974 ] {
3975 sqlx::query(statement)
3976 .execute(&mut *transaction)
3977 .await
3978 .map_err(|e| anyhow::anyhow!("Failed to install verification schema: {e}"))?;
3979 }
3980
3981 sqlx::query(
3982 "LOCK TABLE execution_intent, execution_transaction_hash, \
3983 execution_verification_nonce, execution_verified_finalized_header, \
3984 execution_verification_decision, execution_replacement_scan \
3985 IN ACCESS EXCLUSIVE MODE",
3986 )
3987 .execute(&mut *transaction)
3988 .await
3989 .map_err(|e| anyhow::anyhow!("Failed to lock execution verification state: {e}"))?;
3990
3991 let installed_version = sqlx::query_scalar::<_, i16>(
3992 "SELECT version FROM execution_schema_version \
3993 WHERE component = 'evm_execution_verification'",
3994 )
3995 .fetch_optional(&mut *transaction)
3996 .await
3997 .map_err(|e| anyhow::anyhow!("Failed to read verification schema version: {e}"))?;
3998 if let Some(installed_version) = installed_version {
3999 anyhow::ensure!(
4000 installed_version <= VERIFICATION_SCHEMA_VERSION,
4001 "Execution verification schema version {installed_version} is newer than supported version {VERIFICATION_SCHEMA_VERSION}"
4002 );
4003 }
4004
4005 let current = sqlx::query_as::<_, (String, String, i64, i64)>(
4006 "
4007 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
4008 FROM execution_verification_nonce
4009 WHERE chain_id = $1 AND wallet_address = $2
4010 FOR UPDATE
4011 ",
4012 )
4013 .bind(chain_id)
4014 .bind(bootstrap.wallet_address)
4015 .fetch_optional(&mut *transaction)
4016 .await
4017 .map_err(|e| anyhow::anyhow!("Failed to read canonical nonce ledger: {e}"))?;
4018 let initialized = current.is_some();
4019
4020 let revision = if let Some((manifest_version, manifest_digest, stored_nonce, revision)) =
4021 current
4022 {
4023 anyhow::ensure!(
4024 bootstrap.migration.is_none(),
4025 "Verification migration was supplied for an initialized signer"
4026 );
4027 anyhow::ensure!(
4028 manifest_version == bootstrap.manifest_version
4029 && manifest_digest == bootstrap.manifest_digest,
4030 "Execution verification manifest identity changed"
4031 );
4032 anyhow::ensure!(
4033 stored_nonce == next_canonical_nonce,
4034 "Canonical nonce ledger changed during verification bootstrap"
4035 );
4036
4037 if observed_canonical_nonce != stored_nonce {
4038 let expected_observed_nonce = stored_nonce
4039 .checked_add(1)
4040 .ok_or_else(|| anyhow::anyhow!("Canonical nonce overflow"))?;
4041 anyhow::ensure!(
4042 observed_canonical_nonce == expected_observed_nonce,
4043 "Verified finalized transaction count is outside the owned recovery range"
4044 );
4045 let recovery = sqlx::query_as::<_, (Option<i64>, String, i64)>(
4046 "
4047 SELECT
4048 intent.nonce,
4049 intent.status,
4050 COUNT(hash.id) FILTER (
4051 WHERE hash.current
4052 AND hash.payload_expected
4053 AND ((hash.raw_transaction IS NOT NULL)::INTEGER
4054 + (hash.sealed_transaction IS NOT NULL)::INTEGER) = 1
4055 AND hash.status IN (
4056 'broadcast', 'included', 'replaced', 'dropped', 'reorged'
4057 )
4058 )
4059 FROM execution_intent AS intent
4060 LEFT JOIN execution_transaction_hash AS hash
4061 ON hash.intent_id = intent.id
4062 WHERE intent.chain_id = $1
4063 AND intent.wallet_address = $2
4064 AND intent.active
4065 GROUP BY intent.id, intent.nonce, intent.status
4066 ",
4067 )
4068 .bind(chain_id)
4069 .bind(bootstrap.wallet_address)
4070 .fetch_optional(&mut *transaction)
4071 .await
4072 .map_err(|e| anyhow::anyhow!("Failed to validate nonce recovery ownership: {e}"))?;
4073 let Some((intent_nonce, intent_status, payload_count)) = recovery else {
4074 anyhow::bail!(
4075 "Verified finalized transaction count advanced without an active owned intent"
4076 );
4077 };
4078 anyhow::ensure!(
4079 intent_nonce == Some(stored_nonce)
4080 && matches!(
4081 intent_status.as_str(),
4082 "broadcast" | "included" | "replaced" | "dropped" | "reorged"
4083 )
4084 && payload_count == 1,
4085 "Verified finalized transaction count advanced without one recoverable retained payload at the durable nonce"
4086 );
4087 }
4088 revision
4089 } else {
4090 anyhow::ensure!(
4091 next_canonical_nonce == observed_canonical_nonce,
4092 "Initial canonical nonce conflicts with the verified finalized transaction count"
4093 );
4094 let locked_intents = sqlx::query_as::<_, ExecutionIntentRow>(
4095 "
4096 SELECT
4097 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
4098 client_order_id, trader_id, strategy_id, account_id, instrument_id,
4099 pool_address, transaction_to, transaction_input, transaction_value,
4100 amount_in, created_block, acknowledgement_emitted, fill_emitted,
4101 terminal_emitted, active
4102 FROM execution_intent
4103 WHERE chain_id = $1 AND wallet_address = $2
4104 ORDER BY id
4105 ",
4106 )
4107 .bind(chain_id)
4108 .bind(bootstrap.wallet_address)
4109 .fetch_all(&mut *transaction)
4110 .await
4111 .map_err(|e| anyhow::anyhow!("Failed to lock retained execution intents: {e}"))?;
4112 let locked_hashes = sqlx::query_as::<_, ExecutionTransactionHashRow>(
4113 "
4114 SELECT
4115 hash.id, hash.intent_id, hash.chain_id, hash.transaction_hash,
4116 hash.payload_expected, hash.raw_transaction, hash.sealed_transaction,
4117 hash.status, hash.block_number, hash.block_hash, hash.receipt_success,
4118 hash.gas_used, hash.effective_gas_price, hash.current
4119 FROM execution_transaction_hash AS hash
4120 JOIN execution_intent AS intent ON intent.id = hash.intent_id
4121 WHERE intent.chain_id = $1 AND intent.wallet_address = $2
4122 ORDER BY hash.id
4123 ",
4124 )
4125 .bind(chain_id)
4126 .bind(bootstrap.wallet_address)
4127 .fetch_all(&mut *transaction)
4128 .await
4129 .map_err(|e| anyhow::anyhow!("Failed to lock retained execution hashes: {e}"))?;
4130 if locked_intents.is_empty() {
4131 anyhow::ensure!(
4132 bootstrap.migration.is_none(),
4133 "Verification migration was supplied for empty execution history"
4134 );
4135 } else {
4136 let migration = bootstrap.migration.ok_or_else(|| {
4137 anyhow::anyhow!(
4138 "Retained execution history requires archive verification before migration"
4139 )
4140 })?;
4141 anyhow::ensure!(
4142 migration.snapshot.intents == locked_intents
4143 && migration.snapshot.hashes == locked_hashes,
4144 "Retained execution history changed during verification migration"
4145 );
4146 let intent_ids = locked_intents
4147 .iter()
4148 .map(|intent| intent.id)
4149 .collect::<BTreeSet<_>>();
4150 let record_ids = migration
4151 .records
4152 .iter()
4153 .map(|record| record.intent_id)
4154 .collect::<BTreeSet<_>>();
4155 anyhow::ensure!(
4156 intent_ids == record_ids && record_ids.len() == migration.records.len(),
4157 "Verification migration does not classify every retained intent exactly once"
4158 );
4159 }
4160 sqlx::query(
4161 "
4162 INSERT INTO execution_verification_nonce (
4163 chain_id, wallet_address, manifest_version, manifest_digest,
4164 next_canonical_nonce
4165 )
4166 VALUES ($1, $2, $3, $4, $5)
4167 ",
4168 )
4169 .bind(chain_id)
4170 .bind(bootstrap.wallet_address)
4171 .bind(bootstrap.manifest_version)
4172 .bind(bootstrap.manifest_digest)
4173 .bind(next_canonical_nonce)
4174 .execute(&mut *transaction)
4175 .await
4176 .map_err(|e| anyhow::anyhow!("Failed to initialize canonical nonce ledger: {e}"))?;
4177
4178 if let Some(migration) = bootstrap.migration {
4179 for record in &migration.records {
4180 anyhow::ensure!(
4181 !record.decisions.is_empty(),
4182 "Verification migration evidence is empty for intent {}",
4183 record.intent_id
4184 );
4185
4186 if record.recover_prepared {
4187 anyhow::ensure!(
4188 record.terminal_status.is_none()
4189 && record.transaction_hash.is_none()
4190 && record.nonce.is_none(),
4191 "Prepared recovery migration record is inconsistent"
4192 );
4193 let result = sqlx::query(
4194 "
4195 UPDATE execution_intent
4196 SET status = 'recoverable', active = FALSE, updated_at = NOW()
4197 WHERE id = $1 AND active AND status = 'prepared' AND nonce IS NULL
4198 AND NOT EXISTS (
4199 SELECT 1 FROM execution_transaction_hash WHERE intent_id = $1
4200 )
4201 ",
4202 )
4203 .bind(record.intent_id)
4204 .execute(&mut *transaction)
4205 .await
4206 .map_err(|e| {
4207 anyhow::anyhow!("Failed to recover migrated prepared intent: {e}")
4208 })?;
4209 anyhow::ensure!(
4210 result.rows_affected() == 1,
4211 "Prepared intent {} changed during verification migration",
4212 record.intent_id
4213 );
4214 }
4215
4216 if let Some(status) = record.terminal_status {
4217 anyhow::ensure!(
4218 matches!(
4219 status,
4220 TransactionStatus::Finalized | TransactionStatus::Reverted
4221 ),
4222 "Migration terminal status is not finalized or reverted"
4223 );
4224 let transaction_hash =
4225 record.transaction_hash.as_deref().ok_or_else(|| {
4226 anyhow::anyhow!("Terminal migration record has no transaction hash")
4227 })?;
4228 let block_number = i64::try_from(record.block_number.ok_or_else(|| {
4229 anyhow::anyhow!("Terminal migration record has no block number")
4230 })?)
4231 .context("Migration block exceeds PostgreSQL BIGINT")?;
4232 let block_hash = record.block_hash.as_deref().ok_or_else(|| {
4233 anyhow::anyhow!("Terminal migration record has no block hash")
4234 })?;
4235 let gas_used = i64::try_from(record.gas_used.ok_or_else(|| {
4236 anyhow::anyhow!("Terminal migration record has no gas usage")
4237 })?)
4238 .context("Migration gas usage exceeds PostgreSQL BIGINT")?;
4239 let receipt_success = record.receipt_success.ok_or_else(|| {
4240 anyhow::anyhow!("Terminal migration record has no receipt status")
4241 })?;
4242 anyhow::ensure!(
4243 receipt_success == (status == TransactionStatus::Finalized),
4244 "Migration receipt status conflicts with terminal status"
4245 );
4246 let current_status = sqlx::query_scalar::<_, String>(
4247 "SELECT status FROM execution_intent WHERE id = $1 FOR UPDATE",
4248 )
4249 .bind(record.intent_id)
4250 .fetch_one(&mut *transaction)
4251 .await
4252 .map_err(|e| anyhow::anyhow!("Failed to lock migrated intent: {e}"))?;
4253 let hash_result = sqlx::query(
4254 "
4255 UPDATE execution_transaction_hash
4256 SET status = $3, block_number = $4, block_hash = $5,
4257 receipt_success = $6, gas_used = $7,
4258 effective_gas_price = $8, updated_at = NOW()
4259 WHERE intent_id = $1 AND transaction_hash = $2 AND current
4260 AND payload_expected
4261 ",
4262 )
4263 .bind(record.intent_id)
4264 .bind(transaction_hash)
4265 .bind(status.as_str())
4266 .bind(block_number)
4267 .bind(block_hash)
4268 .bind(receipt_success)
4269 .bind(gas_used)
4270 .bind(record.effective_gas_price.as_deref())
4271 .execute(&mut *transaction)
4272 .await
4273 .map_err(|e| anyhow::anyhow!("Failed to reconstruct migrated hash: {e}"))?;
4274 anyhow::ensure!(
4275 hash_result.rows_affected() == 1,
4276 "Migrated terminal transaction hash was not found"
4277 );
4278 sqlx::query(
4279 "UPDATE execution_intent SET status = $2, updated_at = NOW() WHERE id = $1",
4280 )
4281 .bind(record.intent_id)
4282 .bind(status.as_str())
4283 .execute(&mut *transaction)
4284 .await
4285 .map_err(|e| anyhow::anyhow!("Failed to reconstruct migrated intent: {e}"))?;
4286 if current_status != status.as_str() {
4287 sqlx::query(
4288 "
4289 INSERT INTO execution_transaction_transition (
4290 intent_id, transaction_hash_id, transition_key,
4291 from_status, to_status, block_number, block_hash
4292 )
4293 SELECT $1, id, $3, $4, $5, $6, $7
4294 FROM execution_transaction_hash
4295 WHERE intent_id = $1 AND transaction_hash = $2
4296 ",
4297 )
4298 .bind(record.intent_id)
4299 .bind(transaction_hash)
4300 .bind(format!("migration:{}:{transaction_hash}", status.as_str()))
4301 .bind(current_status)
4302 .bind(status.as_str())
4303 .bind(block_number)
4304 .bind(block_hash)
4305 .execute(&mut *transaction)
4306 .await
4307 .map_err(|e| {
4308 anyhow::anyhow!(
4309 "Failed to record migrated terminal transition: {e}"
4310 )
4311 })?;
4312 }
4313 }
4314
4315 let nonce = record
4316 .nonce
4317 .map(i64::try_from)
4318 .transpose()
4319 .context("Migration nonce exceeds PostgreSQL BIGINT")?;
4320
4321 for (index, decision) in record.decisions.iter().enumerate() {
4322 let height_start = decision
4323 .height_start
4324 .map(i64::try_from)
4325 .transpose()
4326 .context("Migration evidence height exceeds PostgreSQL BIGINT")?;
4327 let height_end = decision
4328 .height_end
4329 .map(i64::try_from)
4330 .transpose()
4331 .context("Migration evidence height exceeds PostgreSQL BIGINT")?;
4332 sqlx::query(
4333 "
4334 INSERT INTO execution_verification_decision (
4335 intent_id, nonce, decision_class, read_class,
4336 height_start, height_end, manifest_version, manifest_digest,
4337 provider_ids, operator_ids, failure_domain_ids,
4338 response_class, normalized_value_digest, nonce_revision,
4339 outcome, transition_key
4340 )
4341 VALUES (
4342 $1, $2, 'migration', $3, $4, $5, $6, $7, $8, $9, $10,
4343 'all_valid', $11, 0, 'verified', $12
4344 )
4345 ",
4346 )
4347 .bind(record.intent_id)
4348 .bind(nonce)
4349 .bind(decision.read_class)
4350 .bind(height_start)
4351 .bind(height_end)
4352 .bind(bootstrap.manifest_version)
4353 .bind(bootstrap.manifest_digest)
4354 .bind(bootstrap.provider_ids)
4355 .bind(bootstrap.operator_ids)
4356 .bind(bootstrap.failure_domain_ids)
4357 .bind(&decision.normalized_value_digest)
4358 .bind(format!("migration:{}:{index}", record.intent_id))
4359 .execute(&mut *transaction)
4360 .await
4361 .map_err(|e| {
4362 anyhow::anyhow!("Failed to persist migration evidence: {e}")
4363 })?;
4364 }
4365 }
4366 }
4367 0
4368 };
4369
4370 sqlx::query(
4371 "
4372 INSERT INTO execution_verified_finalized_header (
4373 chain_id, wallet_address, number, hash, parent_hash, timestamp,
4374 base_fee_per_gas, manifest_digest
4375 )
4376 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
4377 ON CONFLICT (chain_id, wallet_address, number) DO NOTHING
4378 ",
4379 )
4380 .bind(chain_id)
4381 .bind(bootstrap.wallet_address)
4382 .bind(checkpoint_number)
4383 .bind(bootstrap.checkpoint_hash)
4384 .bind(bootstrap.checkpoint_parent_hash)
4385 .bind(checkpoint_timestamp)
4386 .bind(base_fee)
4387 .bind(bootstrap.manifest_digest)
4388 .execute(&mut *transaction)
4389 .await
4390 .map_err(|e| anyhow::anyhow!("Failed to initialize finalized header ledger: {e}"))?;
4391 let stored_checkpoint = sqlx::query_as::<_, (String, String, i64, Option<String>)>(
4392 "
4393 SELECT hash, parent_hash, timestamp, base_fee_per_gas
4394 FROM execution_verified_finalized_header
4395 WHERE chain_id = $1 AND wallet_address = $2 AND number = $3
4396 ",
4397 )
4398 .bind(chain_id)
4399 .bind(bootstrap.wallet_address)
4400 .bind(checkpoint_number)
4401 .fetch_one(&mut *transaction)
4402 .await
4403 .map_err(|e| anyhow::anyhow!("Failed to validate finalized checkpoint ledger: {e}"))?;
4404 anyhow::ensure!(
4405 stored_checkpoint
4406 == (
4407 bootstrap.checkpoint_hash.to_string(),
4408 bootstrap.checkpoint_parent_hash.to_string(),
4409 checkpoint_timestamp,
4410 bootstrap
4411 .checkpoint_base_fee_per_gas
4412 .map(|value| value.to_string()),
4413 ),
4414 "Finalized checkpoint ledger conflicts with the trusted chain anchor"
4415 );
4416
4417 if initialized {
4418 let stored_tip =
4419 sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
4420 "
4421 SELECT number, hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
4422 FROM execution_verified_finalized_header
4423 WHERE chain_id = $1 AND wallet_address = $2
4424 ORDER BY number DESC
4425 LIMIT 1
4426 ",
4427 )
4428 .bind(chain_id)
4429 .bind(bootstrap.wallet_address)
4430 .fetch_one(&mut *transaction)
4431 .await
4432 .map_err(|e| anyhow::anyhow!("Failed to lock finalized header tip: {e}"))?;
4433 anyhow::ensure!(
4434 stored_tip
4435 == (
4436 i64::try_from(first_header.number)
4437 .context("Verified finalized height exceeds PostgreSQL BIGINT")?,
4438 first_header.hash.clone(),
4439 first_header.parent_hash.clone(),
4440 i64::try_from(first_header.timestamp)
4441 .context("Verified finalized timestamp exceeds PostgreSQL BIGINT")?,
4442 first_header.base_fee_per_gas.map(|value| value.to_string()),
4443 bootstrap.manifest_digest.to_string(),
4444 ),
4445 "Verified finalized header extension does not start at the durable tip"
4446 );
4447 } else {
4448 anyhow::ensure!(
4449 first_header.number == bootstrap.checkpoint_number
4450 && first_header.hash == bootstrap.checkpoint_hash
4451 && first_header.parent_hash == bootstrap.checkpoint_parent_hash
4452 && first_header.timestamp == bootstrap.checkpoint_timestamp
4453 && first_header.base_fee_per_gas == bootstrap.checkpoint_base_fee_per_gas,
4454 "Verified finalized headers do not start at the trusted checkpoint"
4455 );
4456 }
4457
4458 for header in bootstrap.finalized_headers.iter().skip(1) {
4459 let number = i64::try_from(header.number)
4460 .context("Verified finalized height exceeds PostgreSQL BIGINT")?;
4461 let timestamp = i64::try_from(header.timestamp)
4462 .context("Verified finalized timestamp exceeds PostgreSQL BIGINT")?;
4463 let base_fee = header.base_fee_per_gas.map(|value| value.to_string());
4464 sqlx::query(
4465 "
4466 INSERT INTO execution_verified_finalized_header (
4467 chain_id, wallet_address, number, hash, parent_hash, timestamp,
4468 base_fee_per_gas, manifest_digest
4469 )
4470 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
4471 ON CONFLICT (chain_id, wallet_address, number) DO NOTHING
4472 ",
4473 )
4474 .bind(chain_id)
4475 .bind(bootstrap.wallet_address)
4476 .bind(number)
4477 .bind(&header.hash)
4478 .bind(&header.parent_hash)
4479 .bind(timestamp)
4480 .bind(&base_fee)
4481 .bind(bootstrap.manifest_digest)
4482 .execute(&mut *transaction)
4483 .await
4484 .map_err(|e| anyhow::anyhow!("Failed to extend finalized header ledger: {e}"))?;
4485 let stored = sqlx::query_as::<_, (String, String, i64, Option<String>, String)>(
4486 "
4487 SELECT hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
4488 FROM execution_verified_finalized_header
4489 WHERE chain_id = $1 AND wallet_address = $2 AND number = $3
4490 ",
4491 )
4492 .bind(chain_id)
4493 .bind(bootstrap.wallet_address)
4494 .bind(number)
4495 .fetch_one(&mut *transaction)
4496 .await
4497 .map_err(|e| anyhow::anyhow!("Failed to validate finalized header ledger: {e}"))?;
4498 anyhow::ensure!(
4499 stored
4500 == (
4501 header.hash.clone(),
4502 header.parent_hash.clone(),
4503 timestamp,
4504 base_fee,
4505 bootstrap.manifest_digest.to_string(),
4506 ),
4507 "Finalized header ledger conflicts at height {}",
4508 header.number
4509 );
4510 }
4511
4512 let finalized_height = bootstrap
4513 .finalized_headers
4514 .last()
4515 .expect("verified finalized headers are nonempty")
4516 .number;
4517
4518 for (index, decision) in bootstrap.decisions.iter().enumerate() {
4519 let height_start = decision
4520 .height_start
4521 .map(i64::try_from)
4522 .transpose()
4523 .context("Connect verification height exceeds PostgreSQL BIGINT")?;
4524 let height_end = decision
4525 .height_end
4526 .map(i64::try_from)
4527 .transpose()
4528 .context("Connect verification height exceeds PostgreSQL BIGINT")?;
4529 let transition_key = format!("connect:{finalized_height}:{revision}:{index}");
4530 sqlx::query(
4531 "
4532 INSERT INTO execution_verification_decision (
4533 intent_id, nonce, decision_class, read_class, height_start, height_end,
4534 manifest_version, manifest_digest, provider_ids, operator_ids,
4535 failure_domain_ids, response_class, normalized_value_digest,
4536 nonce_revision, outcome, transition_key
4537 )
4538 VALUES (
4539 NULL, NULL, 'connect', $1, $2, $3, $4, $5, $6, $7, $8,
4540 'all_valid', $9, $10, 'verified', $11
4541 )
4542 ",
4543 )
4544 .bind(decision.read_class)
4545 .bind(height_start)
4546 .bind(height_end)
4547 .bind(bootstrap.manifest_version)
4548 .bind(bootstrap.manifest_digest)
4549 .bind(bootstrap.provider_ids)
4550 .bind(bootstrap.operator_ids)
4551 .bind(bootstrap.failure_domain_ids)
4552 .bind(&decision.normalized_value_digest)
4553 .bind(revision)
4554 .bind(transition_key)
4555 .execute(&mut *transaction)
4556 .await
4557 .map_err(|e| anyhow::anyhow!("Failed to persist connect verification: {e}"))?;
4558 }
4559
4560 for statement in [
4561 "
4562 CREATE OR REPLACE FUNCTION execution_verification_append_only()
4563 RETURNS TRIGGER AS $$
4564 BEGIN
4565 RAISE EXCEPTION 'Execution verification evidence is append-only';
4566 END;
4567 $$ LANGUAGE plpgsql
4568 ",
4569 "DROP TRIGGER IF EXISTS execution_verification_decision_append_only \
4570 ON execution_verification_decision",
4571 "
4572 CREATE TRIGGER execution_verification_decision_append_only
4573 BEFORE UPDATE OR DELETE ON execution_verification_decision
4574 FOR EACH STATEMENT EXECUTE FUNCTION execution_verification_append_only()
4575 ",
4576 "DROP TRIGGER IF EXISTS execution_finalized_header_append_only \
4577 ON execution_verified_finalized_header",
4578 "
4579 CREATE TRIGGER execution_finalized_header_append_only
4580 BEFORE UPDATE OR DELETE ON execution_verified_finalized_header
4581 FOR EACH STATEMENT EXECUTE FUNCTION execution_verification_append_only()
4582 ",
4583 ] {
4584 sqlx::query(statement)
4585 .execute(&mut *transaction)
4586 .await
4587 .map_err(|e| anyhow::anyhow!("Failed to activate verification schema: {e}"))?;
4588 }
4589 sqlx::query(
4590 "
4591 INSERT INTO execution_schema_version (component, version)
4592 VALUES ('evm_execution_verification', $1)
4593 ON CONFLICT (component) DO UPDATE SET version = EXCLUDED.version
4594 WHERE execution_schema_version.version <= EXCLUDED.version
4595 ",
4596 )
4597 .bind(VERIFICATION_SCHEMA_VERSION)
4598 .execute(&mut *transaction)
4599 .await
4600 .map_err(|e| anyhow::anyhow!("Failed to activate verification schema: {e}"))?;
4601
4602 transaction
4603 .commit()
4604 .await
4605 .map_err(|e| anyhow::anyhow!("Failed to commit verification migration: {e}"))?;
4606 Ok(())
4607 }
4608
4609 pub(crate) async fn ensure_execution_payload_storage(
4611 &self,
4612 keys: &PayloadKeySet,
4613 ) -> anyhow::Result<()> {
4614 let marker = self.execution_payload_marker().await?;
4615 match marker {
4616 None => self.activate_execution_payload_storage(keys).await?,
4617 Some(version) => anyhow::ensure!(
4618 version == EXECUTION_PAYLOAD_PROTOCOL_VERSION,
4619 "Execution payload protection version {version} is newer than supported version {EXECUTION_PAYLOAD_PROTOCOL_VERSION}"
4620 ),
4621 }
4622
4623 loop {
4624 let state = self.execution_payload_state().await?.ok_or_else(|| {
4625 anyhow::anyhow!("Execution payload marker exists without durable state")
4626 })?;
4627 validate_execution_payload_state(&state, keys)?;
4628 match state.operation.as_str() {
4629 "migrate" => {
4630 if self
4631 .migrate_execution_payload_batch(keys, EXECUTION_PAYLOAD_BATCH_SIZE)
4632 .await?
4633 {
4634 break;
4635 }
4636 }
4637 "ready" => break,
4638 operation => anyhow::bail!(
4639 "Execution payload storage is in {operation} maintenance; complete or roll back that operation before connecting"
4640 ),
4641 }
4642 }
4643
4644 self.validate_execution_payload_ready(keys).await
4645 }
4646
4647 pub(crate) async fn require_execution_payload_storage(
4649 &self,
4650 keys: &PayloadKeySet,
4651 policy: PayloadPolicy,
4652 batch_size: i64,
4653 ) -> anyhow::Result<ExecutionPayloadLease> {
4654 self.require_execution_payload_storage_ready(keys).await?;
4655 let (check, transaction) = self
4656 .inspect_execution_payload_storage(Some(keys), Some(policy), batch_size)
4657 .await?;
4658 anyhow::ensure!(
4659 check.protected,
4660 "Postgres execution requires protected payload storage"
4661 );
4662 Ok(ExecutionPayloadLease {
4663 _transaction: transaction,
4664 })
4665 }
4666
4667 pub(crate) async fn require_execution_payload_storage_ready(
4669 &self,
4670 keys: &PayloadKeySet,
4671 ) -> anyhow::Result<()> {
4672 anyhow::ensure!(
4673 self.execution_payload_marker().await?.is_some(),
4674 "Postgres execution requires protected payload storage"
4675 );
4676 self.validate_execution_payload_ready(keys).await
4677 }
4678
4679 pub(crate) async fn acquire_execution_payload_lease(
4681 &self,
4682 keys: &PayloadKeySet,
4683 ) -> anyhow::Result<ExecutionPayloadLease> {
4684 let mut transaction = self
4685 .pool
4686 .begin()
4687 .await
4688 .context("failed to start execution payload action lease")?;
4689 let marker = sqlx::query_scalar::<_, i16>(
4690 "SELECT version FROM execution_schema_version WHERE component = $1",
4691 )
4692 .bind(EXECUTION_PAYLOAD_COMPONENT)
4693 .fetch_optional(&mut *transaction)
4694 .await
4695 .context("failed to read execution payload marker")?;
4696
4697 let version = marker.ok_or_else(|| {
4698 anyhow::anyhow!("Postgres execution requires protected payload storage")
4699 })?;
4700 anyhow::ensure!(
4701 version == EXECUTION_PAYLOAD_PROTOCOL_VERSION,
4702 "Unsupported execution payload protection version {version}"
4703 );
4704 let row = sqlx::query(
4705 "SELECT deployment_id, protocol_version, operation, active_key_id \
4706 FROM execution_payload_state WHERE component = 'signed_transactions' \
4707 FOR SHARE",
4708 )
4709 .fetch_optional(&mut *transaction)
4710 .await
4711 .context("failed to lock execution payload state")?
4712 .ok_or_else(|| anyhow::anyhow!("Execution payload marker exists without state"))?;
4713 let state = execution_payload_state_from_row(&row)?;
4714 validate_execution_payload_state(&state, keys)?;
4715 anyhow::ensure!(
4716 state.operation == "ready",
4717 "Execution payload storage is in {} maintenance",
4718 state.operation
4719 );
4720
4721 Ok(ExecutionPayloadLease {
4722 _transaction: transaction,
4723 })
4724 }
4725
4726 pub(crate) async fn reserve_execution_payload_seal(
4728 &self,
4729 keys: &PayloadKeySet,
4730 ) -> anyhow::Result<()> {
4731 let mut transaction = self
4732 .pool
4733 .begin()
4734 .await
4735 .context("failed to start payload seal reservation")?;
4736 let row = sqlx::query(
4737 "SELECT deployment_id, protocol_version, operation, active_key_id \
4738 FROM execution_payload_state WHERE component = 'signed_transactions' FOR SHARE",
4739 )
4740 .fetch_optional(&mut *transaction)
4741 .await
4742 .context("failed to lock execution payload state")?
4743 .ok_or_else(|| anyhow::anyhow!("Execution payload protection is not active"))?;
4744 let state = execution_payload_state_from_row(&row)?;
4745 validate_execution_payload_state(&state, keys)?;
4746 anyhow::ensure!(
4747 state.operation == "ready",
4748 "Execution payload storage is in {} maintenance",
4749 state.operation
4750 );
4751 reserve_execution_payload_seal(&mut transaction, keys.active_key_id()).await?;
4752 transaction
4753 .commit()
4754 .await
4755 .context("failed to commit payload seal reservation")?;
4756 Ok(())
4757 }
4758
4759 async fn execution_payload_marker(&self) -> anyhow::Result<Option<i16>> {
4760 sqlx::query_scalar::<_, i16>(
4761 "SELECT version FROM execution_schema_version WHERE component = $1",
4762 )
4763 .bind(EXECUTION_PAYLOAD_COMPONENT)
4764 .fetch_optional(&self.pool)
4765 .await
4766 .context("failed to read execution payload marker")
4767 }
4768
4769 async fn execution_payload_state(&self) -> anyhow::Result<Option<ExecutionPayloadState>> {
4770 let row = sqlx::query(
4771 "SELECT deployment_id, protocol_version, operation, active_key_id \
4772 FROM execution_payload_state WHERE component = 'signed_transactions'",
4773 )
4774 .fetch_optional(&self.pool)
4775 .await
4776 .context("failed to read execution payload state")?;
4777 row.as_ref()
4778 .map(execution_payload_state_from_row)
4779 .transpose()
4780 }
4781
4782 async fn activate_execution_payload_storage(&self, keys: &PayloadKeySet) -> anyhow::Result<()> {
4783 let mut transaction = self
4784 .pool
4785 .begin()
4786 .await
4787 .context("failed to start execution payload activation")?;
4788 lock_execution_payload_operation(&mut transaction).await?;
4789 sqlx::query("LOCK TABLE execution_transaction_hash IN ACCESS EXCLUSIVE MODE")
4790 .execute(&mut *transaction)
4791 .await
4792 .context("failed to fence execution payload writers")?;
4793 let marker = sqlx::query_scalar::<_, i16>(
4794 "SELECT version FROM execution_schema_version WHERE component = $1",
4795 )
4796 .bind(EXECUTION_PAYLOAD_COMPONENT)
4797 .fetch_optional(&mut *transaction)
4798 .await
4799 .context("failed to recheck execution payload marker")?;
4800 if marker.is_some() {
4801 transaction
4802 .rollback()
4803 .await
4804 .context("failed to release concurrent payload activation")?;
4805 return Ok(());
4806 }
4807 let sealed_rows = sqlx::query_scalar::<_, i64>(
4808 "SELECT COUNT(*) FROM execution_transaction_hash WHERE sealed_transaction IS NOT NULL",
4809 )
4810 .fetch_one(&mut *transaction)
4811 .await
4812 .context("failed to inspect pre-activation envelopes")?;
4813 anyhow::ensure!(
4814 sealed_rows == 0,
4815 "Unprotected execution payload storage contains {sealed_rows} sealed row(s)"
4816 );
4817
4818 for statement in [
4819 "
4820 CREATE OR REPLACE FUNCTION execution_transaction_payload_fence()
4821 RETURNS TRIGGER AS $$
4822 DECLARE payload_operation TEXT;
4823 BEGIN
4824 IF NEW.raw_transaction IS NOT NULL
4825 AND (TG_OP = 'INSERT'
4826 OR OLD.raw_transaction IS NULL
4827 OR NEW.raw_transaction IS DISTINCT FROM OLD.raw_transaction) THEN
4828 SELECT operation INTO payload_operation
4829 FROM execution_payload_state
4830 WHERE component = 'signed_transactions';
4831 IF NOT (
4832 TG_OP = 'UPDATE'
4833 AND payload_operation = 'rollback'
4834 AND OLD.payload_expected
4835 AND OLD.raw_transaction IS NULL
4836 AND NEW.raw_transaction IS NOT NULL
4837 AND OLD.sealed_transaction IS NOT NULL
4838 AND NEW.sealed_transaction = OLD.sealed_transaction
4839 ) THEN
4840 RAISE EXCEPTION 'Plaintext signed transaction writes are disabled';
4841 END IF;
4842 END IF;
4843 RETURN NEW;
4844 END;
4845 $$ LANGUAGE plpgsql
4846 ",
4847 "DROP TRIGGER IF EXISTS execution_transaction_payload_fence ON execution_transaction_hash",
4848 "
4849 CREATE TRIGGER execution_transaction_payload_fence
4850 BEFORE INSERT OR UPDATE ON execution_transaction_hash
4851 FOR EACH ROW EXECUTE FUNCTION execution_transaction_payload_fence()
4852 ",
4853 ] {
4854 sqlx::query(statement)
4855 .execute(&mut *transaction)
4856 .await
4857 .context("failed to install execution payload write fence")?;
4858 }
4859 sqlx::query("INSERT INTO execution_schema_version (component, version) VALUES ($1, $2)")
4860 .bind(EXECUTION_PAYLOAD_COMPONENT)
4861 .bind(EXECUTION_PAYLOAD_PROTOCOL_VERSION)
4862 .execute(&mut *transaction)
4863 .await
4864 .context("failed to record execution payload marker")?;
4865 sqlx::query(
4866 "INSERT INTO execution_payload_state (component, deployment_id, protocol_version, operation, active_key_id) \
4867 VALUES ('signed_transactions', $1, $2, 'migrate', $3)",
4868 )
4869 .bind(keys.deployment_id())
4870 .bind(EXECUTION_PAYLOAD_PROTOCOL_VERSION)
4871 .bind(keys.active_key_id().as_slice())
4872 .execute(&mut *transaction)
4873 .await
4874 .context("failed to record execution payload migration state")?;
4875 sqlx::query(
4876 "INSERT INTO execution_payload_key_state (key_id, seals) VALUES ($1, 0) \
4877 ON CONFLICT (key_id) DO NOTHING",
4878 )
4879 .bind(keys.active_key_id().as_slice())
4880 .execute(&mut *transaction)
4881 .await
4882 .context("failed to initialize execution payload key state")?;
4883 transaction
4884 .commit()
4885 .await
4886 .context("failed to commit execution payload activation")?;
4887 Ok(())
4888 }
4889
4890 async fn migrate_execution_payload_batch(
4891 &self,
4892 keys: &PayloadKeySet,
4893 batch_size: i64,
4894 ) -> anyhow::Result<bool> {
4895 anyhow::ensure!(
4896 batch_size > 0,
4897 "Execution payload batch size must be positive"
4898 );
4899 let mut transaction = self
4900 .pool
4901 .begin()
4902 .await
4903 .context("failed to start execution payload migration batch")?;
4904 lock_execution_payload_operation(&mut transaction).await?;
4905 let state_row = sqlx::query(
4906 "SELECT deployment_id, protocol_version, operation, active_key_id \
4907 FROM execution_payload_state WHERE component = 'signed_transactions' FOR UPDATE",
4908 )
4909 .fetch_optional(&mut *transaction)
4910 .await
4911 .context("failed to lock execution payload migration state")?
4912 .ok_or_else(|| anyhow::anyhow!("Execution payload migration state is missing"))?;
4913 let state = execution_payload_state_from_row(&state_row)?;
4914 validate_execution_payload_state(&state, keys)?;
4915 anyhow::ensure!(
4916 state.operation == "migrate",
4917 "Execution payload storage is {}, not migrating",
4918 state.operation
4919 );
4920
4921 let rows = sqlx::query_as::<_, ExecutionTransactionHashRow>(
4922 "
4923 SELECT
4924 id, intent_id, chain_id, transaction_hash, payload_expected,
4925 raw_transaction, sealed_transaction, status, block_number, block_hash,
4926 receipt_success, gas_used, effective_gas_price, current
4927 FROM execution_transaction_hash
4928 WHERE payload_expected AND raw_transaction IS NOT NULL
4929 ORDER BY id
4930 LIMIT $1
4931 FOR UPDATE
4932 ",
4933 )
4934 .bind(batch_size)
4935 .fetch_all(&mut *transaction)
4936 .await
4937 .context("failed to load legacy signed transaction batch")?;
4938
4939 if rows.is_empty() {
4940 self.complete_execution_payload_migration(&mut transaction, keys)
4941 .await?;
4942 transaction
4943 .commit()
4944 .await
4945 .context("failed to commit execution payload migration completion")?;
4946 return Ok(true);
4947 }
4948
4949 for hash in rows {
4950 anyhow::ensure!(
4951 hash.sealed_transaction.is_none(),
4952 "Execution transaction {} contains both plaintext and sealed payloads",
4953 hash.id
4954 );
4955 let raw_transaction = hash
4956 .raw_transaction
4957 .as_deref()
4958 .expect("migration query requires plaintext");
4959 let intent = load_execution_intent(&mut transaction, hash.intent_id).await?;
4960 let context = authenticate_retained_payload(
4961 raw_transaction,
4962 &intent,
4963 &hash,
4964 keys.deployment_id(),
4965 )
4966 .with_context(|| format!("failed to authenticate execution payload {}", hash.id))?;
4967 reserve_execution_payload_seal(&mut transaction, keys.active_key_id()).await?;
4968 let envelope = keys.seal(raw_transaction, &context)?;
4969 let unsealed = keys.unseal(&envelope, &context)?;
4970 authenticate_retained_payload(&unsealed, &intent, &hash, keys.deployment_id())?;
4971 anyhow::ensure!(
4972 unsealed == raw_transaction,
4973 "Execution payload {} changed during seal round trip",
4974 hash.id
4975 );
4976 let result = sqlx::query(
4977 "UPDATE execution_transaction_hash \
4978 SET sealed_transaction = $2, raw_transaction = NULL, updated_at = NOW() \
4979 WHERE id = $1 AND raw_transaction = $3 AND sealed_transaction IS NULL",
4980 )
4981 .bind(hash.id)
4982 .bind(&envelope)
4983 .bind(raw_transaction)
4984 .execute(&mut *transaction)
4985 .await
4986 .context("failed to promote execution payload")?;
4987 anyhow::ensure!(
4988 result.rows_affected() == 1,
4989 "Execution payload {} changed during migration",
4990 hash.id
4991 );
4992 sqlx::query(
4993 "UPDATE execution_payload_state SET progress_id = $1, updated_at = NOW() \
4994 WHERE component = 'signed_transactions'",
4995 )
4996 .bind(hash.id)
4997 .execute(&mut *transaction)
4998 .await
4999 .context("failed to record execution payload migration progress")?;
5000 }
5001
5002 transaction
5003 .commit()
5004 .await
5005 .context("failed to commit execution payload migration batch")?;
5006 Ok(false)
5007 }
5008
5009 async fn complete_execution_payload_migration(
5010 &self,
5011 transaction: &mut Transaction<'_, Postgres>,
5012 keys: &PayloadKeySet,
5013 ) -> anyhow::Result<()> {
5014 sqlx::query("LOCK TABLE execution_transaction_hash IN SHARE ROW EXCLUSIVE MODE")
5015 .execute(&mut **transaction)
5016 .await
5017 .context("failed to lock execution payload migration completion")?;
5018 let invalid = sqlx::query_scalar::<_, i64>(
5019 "SELECT COUNT(*) FROM execution_transaction_hash \
5020 WHERE (payload_expected AND (raw_transaction IS NOT NULL OR sealed_transaction IS NULL)) \
5021 OR (NOT payload_expected AND (raw_transaction IS NOT NULL OR sealed_transaction IS NOT NULL))",
5022 )
5023 .fetch_one(&mut **transaction)
5024 .await
5025 .context("failed to verify migrated execution payload representations")?;
5026 anyhow::ensure!(
5027 invalid == 0,
5028 "Execution payload migration found {invalid} invalid representation(s)"
5029 );
5030 validate_execution_payload_key_inventory(transaction, keys).await?;
5031
5032 for statement in [
5033 "ALTER TABLE execution_transaction_hash \
5034 DROP CONSTRAINT IF EXISTS execution_transaction_payload_protected_check",
5035 "ALTER TABLE execution_transaction_hash \
5036 ADD CONSTRAINT execution_transaction_payload_protected_check CHECK ( \
5037 (payload_expected AND raw_transaction IS NULL AND sealed_transaction IS NOT NULL) \
5038 OR (NOT payload_expected AND raw_transaction IS NULL AND sealed_transaction IS NULL) \
5039 )",
5040 ] {
5041 sqlx::query(statement)
5042 .execute(&mut **transaction)
5043 .await
5044 .context("failed to install protected execution payload constraint")?;
5045 }
5046 sqlx::query(
5047 "UPDATE execution_payload_state SET operation = 'ready', updated_at = NOW() \
5048 WHERE component = 'signed_transactions' AND operation = 'migrate'",
5049 )
5050 .execute(&mut **transaction)
5051 .await
5052 .context("failed to mark execution payload storage ready")?;
5053 Ok(())
5054 }
5055
5056 async fn validate_execution_payload_ready(&self, keys: &PayloadKeySet) -> anyhow::Result<()> {
5057 let mut transaction = self
5058 .pool
5059 .begin()
5060 .await
5061 .context("failed to start execution payload validation")?;
5062 let state_row = sqlx::query(
5063 "SELECT deployment_id, protocol_version, operation, active_key_id \
5064 FROM execution_payload_state WHERE component = 'signed_transactions' FOR SHARE",
5065 )
5066 .fetch_optional(&mut *transaction)
5067 .await
5068 .context("failed to lock execution payload state")?
5069 .ok_or_else(|| anyhow::anyhow!("Execution payload state is missing"))?;
5070 let state = execution_payload_state_from_row(&state_row)?;
5071 validate_execution_payload_state(&state, keys)?;
5072 anyhow::ensure!(
5073 state.operation == "ready",
5074 "Execution payload storage is not ready"
5075 );
5076 let (payload_fence, payload_constraint) =
5077 sqlx::query_as::<_, (bool, bool)>(EXECUTION_PAYLOAD_INFRASTRUCTURE_QUERY)
5078 .fetch_one(&mut *transaction)
5079 .await
5080 .context("failed to inspect execution payload protection infrastructure")?;
5081 anyhow::ensure!(
5082 payload_fence && payload_constraint,
5083 "Execution payload storage is marked ready without its write fence or constraint"
5084 );
5085 validate_execution_payload_key_inventory(&mut transaction, keys).await?;
5086 transaction
5087 .commit()
5088 .await
5089 .context("failed to complete execution payload validation")?;
5090 Ok(())
5091 }
5092
5093 pub(crate) async fn check_execution_payload_storage(
5094 &self,
5095 keys: Option<&PayloadKeySet>,
5096 policy: Option<PayloadPolicy>,
5097 batch_size: i64,
5098 ) -> anyhow::Result<ExecutionPayloadCheck> {
5099 let (check, transaction) = self
5100 .inspect_execution_payload_storage(keys, policy, batch_size)
5101 .await?;
5102 transaction
5103 .commit()
5104 .await
5105 .context("failed to complete execution payload check")?;
5106 Ok(check)
5107 }
5108
5109 async fn inspect_execution_payload_storage(
5110 &self,
5111 keys: Option<&PayloadKeySet>,
5112 policy: Option<PayloadPolicy>,
5113 batch_size: i64,
5114 ) -> anyhow::Result<(ExecutionPayloadCheck, Transaction<'static, Postgres>)> {
5115 anyhow::ensure!(
5116 batch_size > 0,
5117 "Execution payload check batch size must be positive"
5118 );
5119 let mut transaction = self
5120 .pool
5121 .begin()
5122 .await
5123 .context("failed to start execution payload check")?;
5124 sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
5125 .execute(&mut *transaction)
5126 .await
5127 .context("failed to stabilize execution payload snapshot")?;
5128 sqlx::query("LOCK TABLE execution_transaction_hash IN SHARE MODE")
5129 .execute(&mut *transaction)
5130 .await
5131 .context("failed to stabilize execution payload check")?;
5132 let marker = sqlx::query_scalar::<_, i16>(
5133 "SELECT version FROM execution_schema_version WHERE component = $1",
5134 )
5135 .bind(EXECUTION_PAYLOAD_COMPONENT)
5136 .fetch_optional(&mut *transaction)
5137 .await
5138 .context("failed to read execution payload marker")?;
5139 let deployment_id = match (marker, keys) {
5140 (None, _) => {
5141 let state = sqlx::query_scalar::<_, bool>(
5142 "SELECT EXISTS (SELECT 1 FROM execution_payload_state) \
5143 OR EXISTS (SELECT 1 FROM execution_transaction_hash \
5144 WHERE sealed_transaction IS NOT NULL)",
5145 )
5146 .fetch_one(&mut *transaction)
5147 .await
5148 .context("failed to check legacy payload state")?;
5149 anyhow::ensure!(!state, "Legacy payload storage contains protected state");
5150 None
5151 }
5152 (Some(version), Some(keys)) => {
5153 anyhow::ensure!(
5154 version == EXECUTION_PAYLOAD_PROTOCOL_VERSION,
5155 "Unsupported execution payload protection version {version}"
5156 );
5157 let state_row = sqlx::query(
5158 "SELECT deployment_id, protocol_version, operation, active_key_id \
5159 FROM execution_payload_state WHERE component = 'signed_transactions' FOR SHARE",
5160 )
5161 .fetch_optional(&mut *transaction)
5162 .await
5163 .context("failed to lock execution payload state")?
5164 .ok_or_else(|| anyhow::anyhow!("Execution payload state is missing"))?;
5165 let state = execution_payload_state_from_row(&state_row)?;
5166 validate_execution_payload_state(&state, keys)?;
5167 anyhow::ensure!(
5168 state.operation == "ready",
5169 "Execution payload storage is not ready"
5170 );
5171 Some(state.deployment_id)
5172 }
5173 (Some(_), None) => anyhow::bail!(
5174 "Execution payload protection is active, but no payload key is configured"
5175 ),
5176 };
5177
5178 let mut cursor = 0_i64;
5179 let mut plaintext_rows = 0_u64;
5180 let mut original_rows = 0_u64;
5181 let mut replacement_rows = 0_u64;
5182 let mut authenticated_rows = 0_u64;
5183 let mut key_ids = BTreeSet::new();
5184
5185 loop {
5186 let rows = sqlx::query_as::<_, ExecutionTransactionHashRow>(
5187 "
5188 SELECT
5189 id, intent_id, chain_id, transaction_hash, payload_expected,
5190 raw_transaction, sealed_transaction, status, block_number, block_hash,
5191 receipt_success, gas_used, effective_gas_price, current
5192 FROM execution_transaction_hash
5193 WHERE id > $1
5194 ORDER BY id
5195 LIMIT $2
5196 ",
5197 )
5198 .bind(cursor)
5199 .bind(batch_size)
5200 .fetch_all(&mut *transaction)
5201 .await
5202 .context("failed to load execution payload check batch")?;
5203
5204 if rows.is_empty() {
5205 break;
5206 }
5207
5208 for hash in &rows {
5209 cursor = hash.id;
5210 plaintext_rows += u64::from(hash.raw_transaction.is_some());
5211 if !hash.payload_expected {
5212 replacement_rows += 1;
5213 anyhow::ensure!(
5214 hash.raw_transaction.is_none() && hash.sealed_transaction.is_none(),
5215 "Replacement execution transaction {} retains signed bytes",
5216 hash.id
5217 );
5218 continue;
5219 }
5220 original_rows += 1;
5221 let intent = load_execution_intent(&mut transaction, hash.intent_id).await?;
5222 let raw_transaction = if let (Some(keys), Some(deployment_id)) =
5223 (keys, deployment_id.as_deref())
5224 {
5225 anyhow::ensure!(
5226 hash.raw_transaction.is_none(),
5227 "Protected execution transaction {} contains plaintext",
5228 hash.id
5229 );
5230 let envelope = hash.sealed_transaction.as_deref().ok_or_else(|| {
5231 anyhow::anyhow!(
5232 "Protected execution transaction {} has no envelope",
5233 hash.id
5234 )
5235 })?;
5236 let key_id = envelope_key_id(envelope)?;
5237 anyhow::ensure!(
5238 keys.contains_key(&key_id),
5239 "Execution transaction {} requires an unavailable payload key",
5240 hash.id
5241 );
5242 key_ids.insert(alloy::hex::encode(key_id));
5243 let context = payload_context(&intent, hash, deployment_id)?;
5244 keys.unseal(envelope, &context)?
5245 } else {
5246 anyhow::ensure!(
5247 hash.sealed_transaction.is_none(),
5248 "Legacy execution transaction {} contains an envelope",
5249 hash.id
5250 );
5251 hash.raw_transaction.clone().ok_or_else(|| {
5252 anyhow::anyhow!("Legacy execution transaction {} has no plaintext", hash.id)
5253 })?
5254 };
5255 authenticate_retained_payload(
5256 &raw_transaction,
5257 &intent,
5258 hash,
5259 deployment_id.as_deref().unwrap_or(""),
5260 )?;
5261
5262 if let Some(policy) = policy
5263 && retained_payload_requires_policy(&intent, hash, policy)?
5264 {
5265 authenticate_payload(
5266 &raw_transaction,
5267 &intent,
5268 hash,
5269 policy,
5270 deployment_id.as_deref().unwrap_or(""),
5271 )
5272 .with_context(|| {
5273 format!(
5274 "execution intent {} transaction {} violates current execution policy",
5275 intent.id, hash.transaction_hash
5276 )
5277 })?;
5278 }
5279 authenticated_rows += 1;
5280 }
5281 }
5282 let read_roles = sqlx::query_scalar::<_, String>(
5283 "SELECT DISTINCT role_name FROM ( \
5284 SELECT tableowner AS role_name FROM pg_tables \
5285 WHERE schemaname = current_schema() AND tablename = 'execution_transaction_hash' \
5286 UNION \
5287 SELECT grantee AS role_name FROM information_schema.role_table_grants \
5288 WHERE table_schema = current_schema() \
5289 AND table_name = 'execution_transaction_hash' \
5290 AND privilege_type = 'SELECT' \
5291 ) AS roles ORDER BY role_name",
5292 )
5293 .fetch_all(&mut *transaction)
5294 .await
5295 .context("failed to inspect execution payload read roles")?;
5296 Ok((
5297 ExecutionPayloadCheck {
5298 protected: deployment_id.is_some(),
5299 deployment_id,
5300 plaintext_rows,
5301 original_rows,
5302 replacement_rows,
5303 authenticated_rows,
5304 key_ids: key_ids.into_iter().collect(),
5305 read_roles,
5306 },
5307 transaction,
5308 ))
5309 }
5310
5311 pub(crate) async fn rewrap_execution_payload_storage(
5312 &self,
5313 keys: &PayloadKeySet,
5314 batch_size: i64,
5315 ) -> anyhow::Result<()> {
5316 anyhow::ensure!(
5317 batch_size > 0,
5318 "Execution payload rewrap batch size must be positive"
5319 );
5320 self.begin_execution_payload_rewrap(keys).await?;
5321
5322 loop {
5323 if self
5324 .rewrap_execution_payload_batch(keys, batch_size)
5325 .await?
5326 {
5327 return Ok(());
5328 }
5329 }
5330 }
5331
5332 async fn begin_execution_payload_rewrap(&self, keys: &PayloadKeySet) -> anyhow::Result<()> {
5333 let mut transaction = self
5334 .pool
5335 .begin()
5336 .await
5337 .context("failed to start execution payload rewrap")?;
5338 lock_execution_payload_operation(&mut transaction).await?;
5339 let state_row = sqlx::query(
5340 "SELECT deployment_id, protocol_version, operation, active_key_id \
5341 FROM execution_payload_state WHERE component = 'signed_transactions' FOR UPDATE",
5342 )
5343 .fetch_optional(&mut *transaction)
5344 .await
5345 .context("failed to lock execution payload state for rewrap")?
5346 .ok_or_else(|| anyhow::anyhow!("Execution payload protection is not active"))?;
5347 let state = execution_payload_state_from_row(&state_row)?;
5348 anyhow::ensure!(
5349 state.protocol_version == EXECUTION_PAYLOAD_PROTOCOL_VERSION
5350 && state.deployment_id == keys.deployment_id(),
5351 "Execution payload rewrap context does not match this database"
5352 );
5353
5354 match state.operation.as_str() {
5355 "ready" => {
5356 let current_id: [u8; 32] = state
5357 .active_key_id
5358 .as_slice()
5359 .try_into()
5360 .context("database active payload key ID is invalid")?;
5361 anyhow::ensure!(
5362 keys.contains_key(¤t_id),
5363 "Current database payload key is not configured for rewrap"
5364 );
5365 validate_execution_payload_key_inventory(&mut transaction, keys).await?;
5366 if current_id == *keys.active_key_id() {
5367 transaction
5368 .commit()
5369 .await
5370 .context("failed to complete no-op execution payload rewrap")?;
5371 return Ok(());
5372 }
5373 sqlx::query(
5374 "UPDATE execution_payload_state \
5375 SET operation = 'rewrap', active_key_id = $1, progress_id = 0, updated_at = NOW() \
5376 WHERE component = 'signed_transactions'",
5377 )
5378 .bind(keys.active_key_id().as_slice())
5379 .execute(&mut *transaction)
5380 .await
5381 .context("failed to record execution payload rewrap target")?;
5382 }
5383 "rewrap" => validate_execution_payload_state(&state, keys)?,
5384 operation => anyhow::bail!(
5385 "Execution payload storage is in {operation} maintenance, not ready for rewrap"
5386 ),
5387 }
5388 sqlx::query(
5389 "INSERT INTO execution_payload_key_state (key_id, seals) VALUES ($1, 0) \
5390 ON CONFLICT (key_id) DO NOTHING",
5391 )
5392 .bind(keys.active_key_id().as_slice())
5393 .execute(&mut *transaction)
5394 .await
5395 .context("failed to initialize rewrap target key state")?;
5396 transaction
5397 .commit()
5398 .await
5399 .context("failed to commit execution payload rewrap state")?;
5400 Ok(())
5401 }
5402
5403 async fn rewrap_execution_payload_batch(
5404 &self,
5405 keys: &PayloadKeySet,
5406 batch_size: i64,
5407 ) -> anyhow::Result<bool> {
5408 let mut transaction = self
5409 .pool
5410 .begin()
5411 .await
5412 .context("failed to start execution payload rewrap batch")?;
5413 lock_execution_payload_operation(&mut transaction).await?;
5414 let state_row = sqlx::query(
5415 "SELECT deployment_id, protocol_version, operation, active_key_id \
5416 FROM execution_payload_state WHERE component = 'signed_transactions' FOR UPDATE",
5417 )
5418 .fetch_optional(&mut *transaction)
5419 .await
5420 .context("failed to lock execution payload rewrap state")?
5421 .ok_or_else(|| anyhow::anyhow!("Execution payload rewrap state is missing"))?;
5422 let state = execution_payload_state_from_row(&state_row)?;
5423 validate_execution_payload_state(&state, keys)?;
5424 if state.operation == "ready" {
5425 transaction
5426 .commit()
5427 .await
5428 .context("failed to complete execution payload rewrap")?;
5429 return Ok(true);
5430 }
5431 anyhow::ensure!(
5432 state.operation == "rewrap",
5433 "Execution payload storage is not rewrapping"
5434 );
5435 let rows = sqlx::query_as::<_, ExecutionTransactionHashRow>(
5436 "
5437 SELECT
5438 id, intent_id, chain_id, transaction_hash, payload_expected,
5439 raw_transaction, sealed_transaction, status, block_number, block_hash,
5440 receipt_success, gas_used, effective_gas_price, current
5441 FROM execution_transaction_hash
5442 WHERE payload_expected
5443 AND substring(sealed_transaction FROM 2 FOR 32) <> $1
5444 ORDER BY id
5445 LIMIT $2
5446 FOR UPDATE
5447 ",
5448 )
5449 .bind(keys.active_key_id().as_slice())
5450 .bind(batch_size)
5451 .fetch_all(&mut *transaction)
5452 .await
5453 .context("failed to load execution payload rewrap batch")?;
5454
5455 if rows.is_empty() {
5456 sqlx::query("LOCK TABLE execution_transaction_hash IN SHARE ROW EXCLUSIVE MODE")
5457 .execute(&mut *transaction)
5458 .await
5459 .context("failed to lock execution payload rewrap completion")?;
5460 let remaining = sqlx::query_scalar::<_, i64>(
5461 "SELECT COUNT(*) FROM execution_transaction_hash \
5462 WHERE payload_expected \
5463 AND (raw_transaction IS NOT NULL OR sealed_transaction IS NULL \
5464 OR substring(sealed_transaction FROM 2 FOR 32) <> $1)",
5465 )
5466 .bind(keys.active_key_id().as_slice())
5467 .fetch_one(&mut *transaction)
5468 .await
5469 .context("failed to verify execution payload rewrap completion")?;
5470 anyhow::ensure!(
5471 remaining == 0,
5472 "Execution payload rewrap left {remaining} row(s)"
5473 );
5474 sqlx::query(
5475 "UPDATE execution_payload_state SET operation = 'ready', updated_at = NOW() \
5476 WHERE component = 'signed_transactions' AND operation = 'rewrap'",
5477 )
5478 .execute(&mut *transaction)
5479 .await
5480 .context("failed to mark execution payload rewrap complete")?;
5481 transaction
5482 .commit()
5483 .await
5484 .context("failed to commit execution payload rewrap completion")?;
5485 return Ok(true);
5486 }
5487
5488 for hash in rows {
5489 let envelope = hash.sealed_transaction.as_deref().ok_or_else(|| {
5490 anyhow::anyhow!(
5491 "Execution payload {} has no envelope during rewrap",
5492 hash.id
5493 )
5494 })?;
5495 anyhow::ensure!(
5496 hash.raw_transaction.is_none(),
5497 "Execution payload {} contains plaintext during rewrap",
5498 hash.id
5499 );
5500 let intent = load_execution_intent(&mut transaction, hash.intent_id).await?;
5501 let context = payload_context(&intent, &hash, keys.deployment_id())?;
5502 let raw_transaction = keys.unseal(envelope, &context)?;
5503 authenticate_retained_payload(&raw_transaction, &intent, &hash, keys.deployment_id())?;
5504 reserve_execution_payload_seal(&mut transaction, keys.active_key_id()).await?;
5505 let rewrapped = keys.seal(&raw_transaction, &context)?;
5506 let verified = keys.unseal(&rewrapped, &context)?;
5507 authenticate_retained_payload(&verified, &intent, &hash, keys.deployment_id())?;
5508 anyhow::ensure!(
5509 verified == raw_transaction,
5510 "Execution payload {} changed during rewrap",
5511 hash.id
5512 );
5513 let result = sqlx::query(
5514 "UPDATE execution_transaction_hash SET sealed_transaction = $2, updated_at = NOW() \
5515 WHERE id = $1 AND sealed_transaction = $3 AND raw_transaction IS NULL",
5516 )
5517 .bind(hash.id)
5518 .bind(&rewrapped)
5519 .bind(envelope)
5520 .execute(&mut *transaction)
5521 .await
5522 .context("failed to persist rewrapped execution payload")?;
5523 anyhow::ensure!(
5524 result.rows_affected() == 1,
5525 "Execution payload {} changed during rewrap",
5526 hash.id
5527 );
5528 sqlx::query(
5529 "UPDATE execution_payload_state SET progress_id = $1, updated_at = NOW() \
5530 WHERE component = 'signed_transactions'",
5531 )
5532 .bind(hash.id)
5533 .execute(&mut *transaction)
5534 .await
5535 .context("failed to record execution payload rewrap progress")?;
5536 }
5537 transaction
5538 .commit()
5539 .await
5540 .context("failed to commit execution payload rewrap batch")?;
5541 Ok(false)
5542 }
5543
5544 pub(crate) async fn rollback_execution_payload_storage(
5545 &self,
5546 keys: &PayloadKeySet,
5547 batch_size: i64,
5548 ) -> anyhow::Result<()> {
5549 anyhow::ensure!(
5550 batch_size > 0,
5551 "Execution payload rollback batch size must be positive"
5552 );
5553 self.begin_execution_payload_rollback(keys).await?;
5554
5555 loop {
5556 if self
5557 .rollback_execution_payload_batch(keys, batch_size)
5558 .await?
5559 {
5560 return Ok(());
5561 }
5562 }
5563 }
5564
5565 async fn begin_execution_payload_rollback(&self, keys: &PayloadKeySet) -> anyhow::Result<()> {
5566 let mut transaction = self
5567 .pool
5568 .begin()
5569 .await
5570 .context("failed to start execution payload rollback")?;
5571 lock_execution_payload_operation(&mut transaction).await?;
5572 let state_row = sqlx::query(
5573 "SELECT deployment_id, protocol_version, operation, active_key_id \
5574 FROM execution_payload_state WHERE component = 'signed_transactions' FOR UPDATE",
5575 )
5576 .fetch_optional(&mut *transaction)
5577 .await
5578 .context("failed to lock execution payload state for rollback")?
5579 .ok_or_else(|| anyhow::anyhow!("Execution payload protection is not active"))?;
5580 let state = execution_payload_state_from_row(&state_row)?;
5581 validate_execution_payload_state(&state, keys)?;
5582 match state.operation.as_str() {
5583 "ready" => {
5584 sqlx::query(
5585 "ALTER TABLE execution_transaction_hash \
5586 DROP CONSTRAINT IF EXISTS execution_transaction_payload_protected_check",
5587 )
5588 .execute(&mut *transaction)
5589 .await
5590 .context("failed to open execution payload rollback representation")?;
5591 sqlx::query(
5592 "UPDATE execution_payload_state \
5593 SET operation = 'rollback', progress_id = 0, updated_at = NOW() \
5594 WHERE component = 'signed_transactions'",
5595 )
5596 .execute(&mut *transaction)
5597 .await
5598 .context("failed to record execution payload rollback state")?;
5599 }
5600 "rollback" => {}
5601 operation => anyhow::bail!(
5602 "Execution payload storage is in {operation} maintenance, not ready for rollback"
5603 ),
5604 }
5605 transaction
5606 .commit()
5607 .await
5608 .context("failed to commit execution payload rollback state")?;
5609 Ok(())
5610 }
5611
5612 async fn rollback_execution_payload_batch(
5613 &self,
5614 keys: &PayloadKeySet,
5615 batch_size: i64,
5616 ) -> anyhow::Result<bool> {
5617 let mut transaction = self
5618 .pool
5619 .begin()
5620 .await
5621 .context("failed to start execution payload rollback batch")?;
5622 lock_execution_payload_operation(&mut transaction).await?;
5623 let state_row = sqlx::query(
5624 "SELECT deployment_id, protocol_version, operation, active_key_id \
5625 FROM execution_payload_state WHERE component = 'signed_transactions' FOR UPDATE",
5626 )
5627 .fetch_optional(&mut *transaction)
5628 .await
5629 .context("failed to lock execution payload rollback state")?
5630 .ok_or_else(|| anyhow::anyhow!("Execution payload rollback state is missing"))?;
5631 let state = execution_payload_state_from_row(&state_row)?;
5632 validate_execution_payload_state(&state, keys)?;
5633 anyhow::ensure!(
5634 state.operation == "rollback",
5635 "Execution payload storage is not rolling back"
5636 );
5637 let rows = sqlx::query_as::<_, ExecutionTransactionHashRow>(
5638 "
5639 SELECT
5640 id, intent_id, chain_id, transaction_hash, payload_expected,
5641 raw_transaction, sealed_transaction, status, block_number, block_hash,
5642 receipt_success, gas_used, effective_gas_price, current
5643 FROM execution_transaction_hash
5644 WHERE payload_expected AND sealed_transaction IS NOT NULL
5645 ORDER BY id
5646 LIMIT $1
5647 FOR UPDATE
5648 ",
5649 )
5650 .bind(batch_size)
5651 .fetch_all(&mut *transaction)
5652 .await
5653 .context("failed to load execution payload rollback batch")?;
5654
5655 if rows.is_empty() {
5656 sqlx::query("LOCK TABLE execution_transaction_hash IN SHARE ROW EXCLUSIVE MODE")
5657 .execute(&mut *transaction)
5658 .await
5659 .context("failed to lock execution payload rollback completion")?;
5660 let invalid = sqlx::query_scalar::<_, i64>(
5661 "SELECT COUNT(*) FROM execution_transaction_hash \
5662 WHERE (payload_expected AND (raw_transaction IS NULL OR sealed_transaction IS NOT NULL)) \
5663 OR (NOT payload_expected AND (raw_transaction IS NOT NULL OR sealed_transaction IS NOT NULL))",
5664 )
5665 .fetch_one(&mut *transaction)
5666 .await
5667 .context("failed to verify execution payload rollback completion")?;
5668 anyhow::ensure!(
5669 invalid == 0,
5670 "Execution payload rollback left {invalid} invalid row(s)"
5671 );
5672
5673 for statement in [
5674 "DROP TRIGGER IF EXISTS execution_transaction_payload_fence ON execution_transaction_hash",
5675 "DELETE FROM execution_payload_state WHERE component = 'signed_transactions'",
5676 "DELETE FROM execution_schema_version WHERE component = 'evm_execution_payload'",
5677 ] {
5678 sqlx::query(statement)
5679 .execute(&mut *transaction)
5680 .await
5681 .context("failed to complete execution payload rollback")?;
5682 }
5683 transaction
5684 .commit()
5685 .await
5686 .context("failed to commit execution payload rollback completion")?;
5687 return Ok(true);
5688 }
5689
5690 for hash in rows {
5691 anyhow::ensure!(
5692 hash.raw_transaction.is_none(),
5693 "Execution payload {} contains both representations during rollback",
5694 hash.id
5695 );
5696 let envelope = hash
5697 .sealed_transaction
5698 .as_deref()
5699 .expect("rollback query requires envelope");
5700 let intent = load_execution_intent(&mut transaction, hash.intent_id).await?;
5701 let context = payload_context(&intent, &hash, keys.deployment_id())?;
5702 let raw_transaction = keys.unseal(envelope, &context)?;
5703 authenticate_retained_payload(&raw_transaction, &intent, &hash, keys.deployment_id())?;
5704 let result = sqlx::query(
5705 "UPDATE execution_transaction_hash SET raw_transaction = $2, updated_at = NOW() \
5706 WHERE id = $1 AND raw_transaction IS NULL AND sealed_transaction = $3",
5707 )
5708 .bind(hash.id)
5709 .bind(&raw_transaction)
5710 .bind(envelope)
5711 .execute(&mut *transaction)
5712 .await
5713 .context("failed to recreate plaintext execution payload")?;
5714 anyhow::ensure!(
5715 result.rows_affected() == 1,
5716 "Execution payload {} changed during rollback",
5717 hash.id
5718 );
5719 authenticate_retained_payload(&raw_transaction, &intent, &hash, keys.deployment_id())?;
5720 let result = sqlx::query(
5721 "UPDATE execution_transaction_hash SET sealed_transaction = NULL, updated_at = NOW() \
5722 WHERE id = $1 AND raw_transaction = $2 AND sealed_transaction = $3",
5723 )
5724 .bind(hash.id)
5725 .bind(&raw_transaction)
5726 .bind(envelope)
5727 .execute(&mut *transaction)
5728 .await
5729 .context("failed to clear rolled-back execution payload envelope")?;
5730 anyhow::ensure!(
5731 result.rows_affected() == 1,
5732 "Execution payload {} changed while clearing rollback envelope",
5733 hash.id
5734 );
5735 sqlx::query(
5736 "UPDATE execution_payload_state SET progress_id = $1, updated_at = NOW() \
5737 WHERE component = 'signed_transactions'",
5738 )
5739 .bind(hash.id)
5740 .execute(&mut *transaction)
5741 .await
5742 .context("failed to record execution payload rollback progress")?;
5743 }
5744 transaction
5745 .commit()
5746 .await
5747 .context("failed to commit execution payload rollback batch")?;
5748 Ok(false)
5749 }
5750
5751 pub async fn reserve_execution_intent(
5759 &self,
5760 intent: &ExecutionIntentInsert,
5761 ) -> anyhow::Result<ExecutionIntentRow> {
5762 let (transaction, row) = async {
5763 let chain_id = i32::try_from(intent.chain_id).with_context(|| {
5764 format!("Chain ID {} exceeds PostgreSQL INTEGER", intent.chain_id)
5765 })?;
5766 let created_block = i64::try_from(intent.created_block).with_context(|| {
5767 format!(
5768 "Execution creation block {} exceeds PostgreSQL BIGINT",
5769 intent.created_block
5770 )
5771 })?;
5772 let mut transaction = self
5773 .pool
5774 .begin()
5775 .await
5776 .context("failed to start execution intent reservation")?;
5777 Self::lock_execution_signer(&mut transaction, intent.chain_id, &intent.wallet_address)
5778 .await?;
5779 let row = sqlx::query_as::<_, ExecutionIntentRow>(
5780 "
5781 INSERT INTO execution_intent (
5782 schema_version, chain_id, wallet_address, purpose, status,
5783 client_order_id, trader_id, strategy_id, account_id, instrument_id,
5784 pool_address, transaction_to, transaction_input, transaction_value,
5785 amount_in, created_block
5786 )
5787 VALUES ($1, $2, $3, $4, 'prepared', $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
5788 RETURNING
5789 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
5790 client_order_id, trader_id, strategy_id, account_id, instrument_id,
5791 pool_address, transaction_to, transaction_input, transaction_value,
5792 amount_in, created_block, acknowledgement_emitted, fill_emitted,
5793 terminal_emitted, active
5794 ",
5795 )
5796 .bind(EXECUTION_SCHEMA_VERSION)
5797 .bind(chain_id)
5798 .bind(&intent.wallet_address)
5799 .bind(&intent.purpose)
5800 .bind(&intent.client_order_id)
5801 .bind(&intent.trader_id)
5802 .bind(&intent.strategy_id)
5803 .bind(&intent.account_id)
5804 .bind(&intent.instrument_id)
5805 .bind(&intent.pool_address)
5806 .bind(&intent.transaction_to)
5807 .bind(&intent.transaction_input)
5808 .bind(&intent.transaction_value)
5809 .bind(&intent.amount_in)
5810 .bind(created_block)
5811 .fetch_one(&mut *transaction)
5812 .await
5813 .context("failed to insert execution intent reservation")?;
5814
5815 sqlx::query(
5816 "
5817 INSERT INTO execution_transaction_transition (
5818 intent_id, transition_key, to_status, block_number
5819 ) VALUES ($1, 'prepared', 'prepared', $2)
5820 ",
5821 )
5822 .bind(row.id)
5823 .bind(created_block)
5824 .execute(&mut *transaction)
5825 .await
5826 .context("failed to record prepared execution intent")?;
5827
5828 Ok::<_, anyhow::Error>((transaction, row))
5829 }
5830 .await
5831 .map_err(|source| {
5832 anyhow::Error::new(ExecutionIntentReservationError {
5833 stage: ExecutionIntentReservationStage::BeforeCommit,
5834 source,
5835 })
5836 })?;
5837 transaction.commit().await.map_err(|e| {
5838 anyhow::Error::new(ExecutionIntentReservationError {
5839 stage: ExecutionIntentReservationStage::Commit,
5840 source: anyhow::Error::new(e)
5841 .context("failed to commit execution intent reservation"),
5842 })
5843 })?;
5844 Ok(row)
5845 }
5846
5847 pub async fn assign_execution_intent_nonce(
5856 &self,
5857 intent_id: i64,
5858 nonce: u64,
5859 ) -> anyhow::Result<()> {
5860 let nonce_db = i64::try_from(nonce)
5861 .with_context(|| format!("Execution nonce {nonce} exceeds PostgreSQL BIGINT"))?;
5862 let result = sqlx::query(
5863 "
5864 UPDATE execution_intent
5865 SET nonce = $2, updated_at = NOW()
5866 WHERE id = $1
5867 AND status = 'prepared'
5868 AND (nonce IS NULL OR nonce = $2)
5869 ",
5870 )
5871 .bind(intent_id)
5872 .bind(nonce_db)
5873 .execute(&self.pool)
5874 .await
5875 .map_err(|e| anyhow::anyhow!("Failed to assign nonce {nonce} to execution intent: {e}"))?;
5876 anyhow::ensure!(
5877 result.rows_affected() == 1,
5878 "Execution intent {intent_id} is not prepared for nonce {nonce}"
5879 );
5880 Ok(())
5881 }
5882
5883 pub(crate) async fn assign_execution_intent_nonce_verified(
5890 &self,
5891 assignment: &ExecutionNonceAssignment<'_>,
5892 ) -> anyhow::Result<()> {
5893 anyhow::ensure!(
5894 !assignment.decisions.is_empty(),
5895 "Verified nonce assignment requires decision evidence"
5896 );
5897 anyhow::ensure!(
5898 assignment.provider_ids.len() == 3 && assignment.operator_ids.len() == 3,
5899 "Verified nonce assignment requires exactly three provider and operator IDs"
5900 );
5901 anyhow::ensure!(
5902 assignment.failure_domain_ids.len() >= 3,
5903 "Verified nonce assignment requires the configured failure domains"
5904 );
5905 let chain_id = i32::try_from(assignment.chain_id)
5906 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
5907 let nonce =
5908 i64::try_from(assignment.nonce).context("Execution nonce exceeds PostgreSQL BIGINT")?;
5909 let mut transaction = self
5910 .pool
5911 .begin()
5912 .await
5913 .map_err(|e| anyhow::anyhow!("Failed to start verified nonce assignment: {e}"))?;
5914 let (manifest_version, manifest_digest, next_nonce, revision) =
5915 sqlx::query_as::<_, (String, String, i64, i64)>(
5916 "
5917 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
5918 FROM execution_verification_nonce
5919 WHERE chain_id = $1 AND wallet_address = $2
5920 FOR UPDATE
5921 ",
5922 )
5923 .bind(chain_id)
5924 .bind(assignment.wallet_address)
5925 .fetch_optional(&mut *transaction)
5926 .await
5927 .map_err(|e| anyhow::anyhow!("Failed to lock canonical nonce ledger: {e}"))?
5928 .ok_or_else(|| anyhow::anyhow!("Canonical nonce ledger is not initialized"))?;
5929 anyhow::ensure!(
5930 manifest_version == assignment.manifest_version
5931 && manifest_digest == assignment.manifest_digest,
5932 "Verified nonce assignment manifest identity changed"
5933 );
5934 anyhow::ensure!(
5935 next_nonce == nonce,
5936 "Execution nonce {} does not match canonical nonce {next_nonce}",
5937 assignment.nonce
5938 );
5939
5940 let (intent_chain_id, intent_wallet, intent_nonce, intent_status, intent_active) =
5941 sqlx::query_as::<_, (i32, String, Option<i64>, String, bool)>(
5942 "
5943 SELECT chain_id, wallet_address, nonce, status, active
5944 FROM execution_intent
5945 WHERE id = $1
5946 FOR UPDATE
5947 ",
5948 )
5949 .bind(assignment.intent_id)
5950 .fetch_optional(&mut *transaction)
5951 .await
5952 .map_err(|e| {
5953 anyhow::anyhow!("Failed to lock execution intent for nonce assignment: {e}")
5954 })?
5955 .ok_or_else(|| {
5956 anyhow::anyhow!("Execution intent {} was not found", assignment.intent_id)
5957 })?;
5958 anyhow::ensure!(
5959 intent_chain_id == chain_id
5960 && intent_wallet == assignment.wallet_address
5961 && intent_status == "prepared"
5962 && intent_active
5963 && intent_nonce.is_none_or(|assigned| assigned == nonce),
5964 "Execution intent {} cannot own canonical nonce {}",
5965 assignment.intent_id,
5966 assignment.nonce
5967 );
5968
5969 for (index, decision) in assignment.decisions.iter().enumerate() {
5970 let height_start = decision
5971 .height_start
5972 .map(i64::try_from)
5973 .transpose()
5974 .context("Verification height exceeds PostgreSQL BIGINT")?;
5975 let height_end = decision
5976 .height_end
5977 .map(i64::try_from)
5978 .transpose()
5979 .context("Verification height exceeds PostgreSQL BIGINT")?;
5980 let transition_key = format!(
5981 "pre_sign:{}:{}:{index}",
5982 assignment.intent_id, decision.read_class
5983 );
5984 sqlx::query(
5985 "
5986 INSERT INTO execution_verification_decision (
5987 intent_id, nonce, decision_class, read_class, height_start, height_end,
5988 manifest_version, manifest_digest, provider_ids, operator_ids,
5989 failure_domain_ids, response_class, normalized_value_digest,
5990 nonce_revision, outcome, transition_key
5991 )
5992 VALUES (
5993 $1, $2, 'pre_sign', $3, $4, $5, $6, $7, $8, $9, $10,
5994 'all_valid', $11, $12, 'verified', $13
5995 )
5996 ",
5997 )
5998 .bind(assignment.intent_id)
5999 .bind(nonce)
6000 .bind(decision.read_class)
6001 .bind(height_start)
6002 .bind(height_end)
6003 .bind(assignment.manifest_version)
6004 .bind(assignment.manifest_digest)
6005 .bind(assignment.provider_ids)
6006 .bind(assignment.operator_ids)
6007 .bind(assignment.failure_domain_ids)
6008 .bind(&decision.normalized_value_digest)
6009 .bind(revision)
6010 .bind(transition_key)
6011 .execute(&mut *transaction)
6012 .await
6013 .map_err(|e| anyhow::anyhow!("Failed to persist pre-sign verification: {e}"))?;
6014 }
6015
6016 let result = sqlx::query(
6017 "
6018 UPDATE execution_intent
6019 SET nonce = $2, updated_at = NOW()
6020 WHERE id = $1
6021 AND status = 'prepared'
6022 AND active
6023 AND (nonce IS NULL OR nonce = $2)
6024 ",
6025 )
6026 .bind(assignment.intent_id)
6027 .bind(nonce)
6028 .execute(&mut *transaction)
6029 .await
6030 .map_err(|e| anyhow::anyhow!("Failed to assign verified execution nonce: {e}"))?;
6031 anyhow::ensure!(
6032 result.rows_affected() == 1,
6033 "Execution intent {} is not prepared for canonical nonce {}",
6034 assignment.intent_id,
6035 assignment.nonce
6036 );
6037 transaction
6038 .commit()
6039 .await
6040 .map_err(|e| anyhow::anyhow!("Failed to commit verified nonce assignment: {e}"))?;
6041 Ok(())
6042 }
6043
6044 pub(crate) async fn record_execution_verification_batch(
6046 &self,
6047 batch: &ExecutionVerificationBatch<'_>,
6048 ) -> anyhow::Result<()> {
6049 anyhow::ensure!(
6050 !batch.decision_class.trim().is_empty() && !batch.decisions.is_empty(),
6051 "Verified action requires a decision class and evidence"
6052 );
6053 anyhow::ensure!(
6054 batch.provider_ids.len() == 3
6055 && batch.operator_ids.len() == 3
6056 && batch.failure_domain_ids.len() >= 3,
6057 "Verified action requires the configured provider identities"
6058 );
6059 let chain_id = i32::try_from(batch.chain_id)
6060 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
6061 let nonce =
6062 i64::try_from(batch.nonce).context("Execution nonce exceeds PostgreSQL BIGINT")?;
6063 let mut transaction = self
6064 .pool
6065 .begin()
6066 .await
6067 .map_err(|e| anyhow::anyhow!("Failed to start verified action evidence: {e}"))?;
6068 let (manifest_version, manifest_digest, revision) =
6069 sqlx::query_as::<_, (String, String, i64)>(
6070 "
6071 SELECT manifest_version, manifest_digest, revision
6072 FROM execution_verification_nonce
6073 WHERE chain_id = $1 AND wallet_address = $2
6074 FOR SHARE
6075 ",
6076 )
6077 .bind(chain_id)
6078 .bind(batch.wallet_address)
6079 .fetch_optional(&mut *transaction)
6080 .await
6081 .map_err(|e| anyhow::anyhow!("Failed to read verified action nonce ledger: {e}"))?
6082 .ok_or_else(|| anyhow::anyhow!("Canonical nonce ledger is not initialized"))?;
6083 anyhow::ensure!(
6084 manifest_version == batch.manifest_version && manifest_digest == batch.manifest_digest,
6085 "Verified action manifest identity changed"
6086 );
6087 let intent_nonce = sqlx::query_scalar::<_, Option<i64>>(
6088 "
6089 SELECT nonce
6090 FROM execution_intent
6091 WHERE id = $1 AND chain_id = $2 AND wallet_address = $3 AND active
6092 FOR UPDATE
6093 ",
6094 )
6095 .bind(batch.intent_id)
6096 .bind(chain_id)
6097 .bind(batch.wallet_address)
6098 .fetch_optional(&mut *transaction)
6099 .await
6100 .map_err(|e| anyhow::anyhow!("Failed to lock intent for verified action: {e}"))?
6101 .ok_or_else(|| anyhow::anyhow!("Active verified-action intent was not found"))?;
6102 anyhow::ensure!(
6103 intent_nonce == Some(nonce),
6104 "Verified action nonce does not match the active intent"
6105 );
6106 let attempt = sqlx::query_scalar::<_, i64>(
6107 "
6108 SELECT COUNT(*)
6109 FROM execution_verification_decision
6110 WHERE intent_id = $1 AND decision_class = $2
6111 ",
6112 )
6113 .bind(batch.intent_id)
6114 .bind(batch.decision_class)
6115 .fetch_one(&mut *transaction)
6116 .await
6117 .map_err(|e| anyhow::anyhow!("Failed to number verified action evidence: {e}"))?;
6118
6119 for (index, decision) in batch.decisions.iter().enumerate() {
6120 let height_start = decision
6121 .height_start
6122 .map(i64::try_from)
6123 .transpose()
6124 .context("Verification height exceeds PostgreSQL BIGINT")?;
6125 let height_end = decision
6126 .height_end
6127 .map(i64::try_from)
6128 .transpose()
6129 .context("Verification height exceeds PostgreSQL BIGINT")?;
6130 let transition_key = format!(
6131 "{}:{}:{attempt}:{}:{index}",
6132 batch.decision_class, batch.intent_id, decision.read_class
6133 );
6134 sqlx::query(
6135 "
6136 INSERT INTO execution_verification_decision (
6137 intent_id, nonce, decision_class, read_class, height_start, height_end,
6138 manifest_version, manifest_digest, provider_ids, operator_ids,
6139 failure_domain_ids, response_class, normalized_value_digest,
6140 nonce_revision, outcome, transition_key
6141 )
6142 VALUES (
6143 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
6144 'all_valid', $12, $13, 'verified', $14
6145 )
6146 ",
6147 )
6148 .bind(batch.intent_id)
6149 .bind(nonce)
6150 .bind(batch.decision_class)
6151 .bind(decision.read_class)
6152 .bind(height_start)
6153 .bind(height_end)
6154 .bind(batch.manifest_version)
6155 .bind(batch.manifest_digest)
6156 .bind(batch.provider_ids)
6157 .bind(batch.operator_ids)
6158 .bind(batch.failure_domain_ids)
6159 .bind(&decision.normalized_value_digest)
6160 .bind(revision)
6161 .bind(transition_key)
6162 .execute(&mut *transaction)
6163 .await
6164 .map_err(|e| anyhow::anyhow!("Failed to persist verified action evidence: {e}"))?;
6165 }
6166 transaction
6167 .commit()
6168 .await
6169 .map_err(|e| anyhow::anyhow!("Failed to commit verified action evidence: {e}"))?;
6170 Ok(())
6171 }
6172
6173 pub(crate) async fn load_execution_replacement_cursor(
6174 &self,
6175 intent_id: i64,
6176 chain_id: u32,
6177 wallet_address: &str,
6178 nonce: u64,
6179 manifest_digest: &str,
6180 ) -> anyhow::Result<Option<ExecutionVerifiedHeader>> {
6181 let chain_id = i32::try_from(chain_id)
6182 .context("Replacement scan chain ID exceeds PostgreSQL INTEGER")?;
6183 let nonce =
6184 i64::try_from(nonce).context("Replacement scan nonce exceeds PostgreSQL BIGINT")?;
6185 let row = sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
6186 "
6187 SELECT
6188 h.number, h.hash, h.parent_hash, h.timestamp, h.base_fee_per_gas,
6189 s.manifest_digest
6190 FROM execution_replacement_scan AS s
6191 JOIN execution_verified_finalized_header AS h
6192 ON h.chain_id = s.chain_id
6193 AND h.wallet_address = s.wallet_address
6194 AND h.number = s.finalized_cursor_number
6195 AND h.hash = s.finalized_cursor_hash
6196 WHERE s.intent_id = $1
6197 AND s.chain_id = $2
6198 AND s.wallet_address = $3
6199 AND s.nonce = $4
6200 ",
6201 )
6202 .bind(intent_id)
6203 .bind(chain_id)
6204 .bind(wallet_address)
6205 .bind(nonce)
6206 .fetch_optional(&self.pool)
6207 .await
6208 .context("failed to load replacement scan cursor")?;
6209 row.map(
6210 |(number, hash, parent_hash, timestamp, base_fee, stored_digest)| {
6211 anyhow::ensure!(
6212 stored_digest == manifest_digest,
6213 "Replacement scan manifest identity changed"
6214 );
6215 Ok(ExecutionVerifiedHeader {
6216 number: u64::try_from(number)
6217 .context("Replacement scan cursor number is negative")?,
6218 hash,
6219 parent_hash,
6220 timestamp: u64::try_from(timestamp)
6221 .context("Replacement scan cursor timestamp is negative")?,
6222 base_fee_per_gas: base_fee
6223 .map(|value| {
6224 value.parse::<u128>().map_err(|_| {
6225 anyhow::anyhow!("Replacement scan cursor base fee is invalid")
6226 })
6227 })
6228 .transpose()?,
6229 })
6230 },
6231 )
6232 .transpose()
6233 }
6234
6235 pub(crate) async fn record_execution_replacement_scan(
6236 &self,
6237 scan: &ExecutionReplacementScan<'_>,
6238 ) -> anyhow::Result<()> {
6239 anyhow::ensure!(
6240 !scan.decisions.is_empty()
6241 && scan.provider_ids.len() == 3
6242 && scan.operator_ids.len() == 3
6243 && scan.failure_domain_ids.len() >= 3,
6244 "Replacement scan verification evidence is incomplete"
6245 );
6246 let chain_id = i32::try_from(scan.chain_id)
6247 .context("Replacement scan chain ID exceeds PostgreSQL INTEGER")?;
6248 let nonce = i64::try_from(scan.nonce)
6249 .context("Replacement scan nonce exceeds PostgreSQL BIGINT")?;
6250 let mut transaction = self
6251 .pool
6252 .begin()
6253 .await
6254 .context("failed to start verified replacement scan transition")?;
6255 let (stored_version, stored_digest, stored_nonce, revision) =
6256 sqlx::query_as::<_, (String, String, i64, i64)>(
6257 "
6258 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
6259 FROM execution_verification_nonce
6260 WHERE chain_id = $1 AND wallet_address = $2
6261 FOR SHARE
6262 ",
6263 )
6264 .bind(chain_id)
6265 .bind(scan.wallet_address)
6266 .fetch_optional(&mut *transaction)
6267 .await
6268 .context("failed to read replacement scan nonce ledger")?
6269 .ok_or_else(|| anyhow::anyhow!("Canonical nonce ledger is not initialized"))?;
6270 anyhow::ensure!(
6271 stored_version == scan.manifest_version
6272 && stored_digest == scan.manifest_digest
6273 && stored_nonce == nonce,
6274 "Replacement scan conflicts with the canonical nonce or manifest ledger"
6275 );
6276 let current_status = sqlx::query_scalar::<_, String>(
6277 "
6278 SELECT status
6279 FROM execution_intent
6280 WHERE id = $1 AND chain_id = $2 AND wallet_address = $3
6281 AND nonce = $4 AND active
6282 FOR UPDATE
6283 ",
6284 )
6285 .bind(scan.intent_id)
6286 .bind(chain_id)
6287 .bind(scan.wallet_address)
6288 .bind(nonce)
6289 .fetch_optional(&mut *transaction)
6290 .await
6291 .context("failed to lock the replacement scan intent")?
6292 .ok_or_else(|| anyhow::anyhow!("Active replacement scan intent was not found"))?;
6293
6294 if let Some(cursor) = scan.finalized_cursor {
6295 let number = i64::try_from(cursor.number)
6296 .context("Replacement scan cursor exceeds PostgreSQL BIGINT")?;
6297 let durable_hash = sqlx::query_scalar::<_, String>(
6298 "
6299 SELECT hash
6300 FROM execution_verified_finalized_header
6301 WHERE chain_id = $1 AND wallet_address = $2 AND number = $3
6302 ",
6303 )
6304 .bind(chain_id)
6305 .bind(scan.wallet_address)
6306 .bind(number)
6307 .fetch_optional(&mut *transaction)
6308 .await
6309 .context("failed to validate replacement cursor against finalized headers")?
6310 .ok_or_else(|| {
6311 anyhow::anyhow!(
6312 "Replacement scan cursor {} is not durably finalized",
6313 cursor.number
6314 )
6315 })?;
6316 anyhow::ensure!(
6317 durable_hash == cursor.hash,
6318 "Replacement scan cursor conflicts with the finalized header ledger"
6319 );
6320 let existing = sqlx::query_as::<_, (i64, String)>(
6321 "
6322 SELECT finalized_cursor_number, finalized_cursor_hash
6323 FROM execution_replacement_scan
6324 WHERE intent_id = $1
6325 FOR UPDATE
6326 ",
6327 )
6328 .bind(scan.intent_id)
6329 .fetch_optional(&mut *transaction)
6330 .await
6331 .context("failed to lock replacement scan progress")?;
6332
6333 if let Some((stored_number, stored_hash)) = existing {
6334 anyhow::ensure!(
6335 number > stored_number
6336 || (number == stored_number && cursor.hash == stored_hash),
6337 "Replacement scan cursor regressed or changed"
6338 );
6339 }
6340 sqlx::query(
6341 "
6342 INSERT INTO execution_replacement_scan (
6343 intent_id, chain_id, wallet_address, nonce,
6344 finalized_cursor_number, finalized_cursor_hash, manifest_digest
6345 ) VALUES ($1, $2, $3, $4, $5, $6, $7)
6346 ON CONFLICT (intent_id) DO UPDATE SET
6347 finalized_cursor_number = EXCLUDED.finalized_cursor_number,
6348 finalized_cursor_hash = EXCLUDED.finalized_cursor_hash,
6349 updated_at = NOW()
6350 ",
6351 )
6352 .bind(scan.intent_id)
6353 .bind(chain_id)
6354 .bind(scan.wallet_address)
6355 .bind(nonce)
6356 .bind(number)
6357 .bind(&cursor.hash)
6358 .bind(scan.manifest_digest)
6359 .execute(&mut *transaction)
6360 .await
6361 .context("failed to persist replacement scan cursor")?;
6362 }
6363
6364 let attempt = sqlx::query_scalar::<_, i64>(
6365 "
6366 SELECT COUNT(*)
6367 FROM execution_verification_decision
6368 WHERE intent_id = $1 AND decision_class = 'replacement_scan'
6369 ",
6370 )
6371 .bind(scan.intent_id)
6372 .fetch_one(&mut *transaction)
6373 .await
6374 .context("failed to number replacement scan evidence")?;
6375 for (index, decision) in scan.decisions.iter().enumerate() {
6376 let height_start = decision
6377 .height_start
6378 .map(i64::try_from)
6379 .transpose()
6380 .context("Replacement scan height exceeds PostgreSQL BIGINT")?;
6381 let height_end = decision
6382 .height_end
6383 .map(i64::try_from)
6384 .transpose()
6385 .context("Replacement scan height exceeds PostgreSQL BIGINT")?;
6386 let transition_key = format!(
6387 "replacement_scan:{}:{attempt}:{}:{index}",
6388 scan.intent_id, decision.read_class
6389 );
6390 sqlx::query(
6391 "
6392 INSERT INTO execution_verification_decision (
6393 intent_id, nonce, decision_class, read_class, height_start, height_end,
6394 manifest_version, manifest_digest, provider_ids, operator_ids,
6395 failure_domain_ids, response_class, normalized_value_digest,
6396 nonce_revision, outcome, transition_key
6397 ) VALUES (
6398 $1, $2, 'replacement_scan', $3, $4, $5, $6, $7, $8, $9, $10,
6399 'all_valid', $11, $12, 'verified', $13
6400 )
6401 ",
6402 )
6403 .bind(scan.intent_id)
6404 .bind(nonce)
6405 .bind(decision.read_class)
6406 .bind(height_start)
6407 .bind(height_end)
6408 .bind(scan.manifest_version)
6409 .bind(scan.manifest_digest)
6410 .bind(scan.provider_ids)
6411 .bind(scan.operator_ids)
6412 .bind(scan.failure_domain_ids)
6413 .bind(&decision.normalized_value_digest)
6414 .bind(revision)
6415 .bind(transition_key)
6416 .execute(&mut *transaction)
6417 .await
6418 .context("failed to persist replacement scan evidence")?;
6419 }
6420
6421 if let Some(transaction_hash) = scan.matched_transaction_hash {
6422 anyhow::ensure!(
6423 execution_transition_allowed(¤t_status, TransactionStatus::Replaced),
6424 "Intent cannot attach a verified replacement from status {current_status}"
6425 );
6426 let (hash_id, payload_expected, already_current) =
6427 sqlx::query_as::<_, (i64, bool, bool)>(
6428 "
6429 SELECT id, payload_expected, current
6430 FROM execution_transaction_hash
6431 WHERE intent_id = $1 AND chain_id = $2 AND transaction_hash = $3
6432 FOR UPDATE
6433 ",
6434 )
6435 .bind(scan.intent_id)
6436 .bind(chain_id)
6437 .bind(transaction_hash)
6438 .fetch_optional(&mut *transaction)
6439 .await
6440 .context("failed to lock authenticated replacement payload")?
6441 .ok_or_else(|| {
6442 anyhow::anyhow!(
6443 "Replacement transaction {transaction_hash} has no retained payload"
6444 )
6445 })?;
6446 anyhow::ensure!(
6447 payload_expected,
6448 "Replacement transaction {transaction_hash} is not authenticated"
6449 );
6450
6451 if !already_current {
6452 sqlx::query(
6453 "
6454 UPDATE execution_transaction_hash
6455 SET current = FALSE, status = 'replaced', updated_at = NOW()
6456 WHERE intent_id = $1 AND current
6457 ",
6458 )
6459 .bind(scan.intent_id)
6460 .execute(&mut *transaction)
6461 .await
6462 .context("failed to retire the prior transaction hash")?;
6463 sqlx::query(
6464 "
6465 UPDATE execution_transaction_hash
6466 SET current = TRUE, status = 'replaced', updated_at = NOW()
6467 WHERE id = $1
6468 ",
6469 )
6470 .bind(hash_id)
6471 .execute(&mut *transaction)
6472 .await
6473 .context("failed to attach the authenticated replacement hash")?;
6474 sqlx::query(
6475 "UPDATE execution_intent SET status = 'replaced', updated_at = NOW() WHERE id = $1",
6476 )
6477 .bind(scan.intent_id)
6478 .execute(&mut *transaction)
6479 .await
6480 .context("failed to mark the execution intent replaced")?;
6481 sqlx::query(
6482 "
6483 INSERT INTO execution_transaction_transition (
6484 intent_id, transaction_hash_id, transition_key, from_status, to_status
6485 ) VALUES ($1, $2, $3, $4, 'replaced')
6486 ON CONFLICT (intent_id, transition_key) DO NOTHING
6487 ",
6488 )
6489 .bind(scan.intent_id)
6490 .bind(hash_id)
6491 .bind(format!("replacement_verified:{transaction_hash}"))
6492 .bind(current_status)
6493 .execute(&mut *transaction)
6494 .await
6495 .context("failed to record the verified replacement transition")?;
6496 }
6497 }
6498
6499 transaction
6500 .commit()
6501 .await
6502 .context("failed to commit verified replacement scan")?;
6503 Ok(())
6504 }
6505
6506 pub async fn mark_execution_intent_recoverable(&self, intent_id: i64) -> anyhow::Result<()> {
6512 let mut transaction = self.pool.begin().await.map_err(|e| {
6513 anyhow::anyhow!("Failed to start recoverable execution transition: {e}")
6514 })?;
6515 let current_status = sqlx::query_scalar::<_, String>(
6516 "SELECT status FROM execution_intent WHERE id = $1 FOR UPDATE",
6517 )
6518 .bind(intent_id)
6519 .fetch_optional(&mut *transaction)
6520 .await
6521 .map_err(|e| anyhow::anyhow!("Failed to lock recoverable execution intent: {e}"))?
6522 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))?;
6523 anyhow::ensure!(
6524 current_status == "prepared",
6525 "Execution intent {intent_id} is {current_status}, not recoverable before signing"
6526 );
6527 let result = sqlx::query(
6528 "
6529 UPDATE execution_intent
6530 SET status = 'recoverable', active = FALSE, updated_at = NOW()
6531 WHERE id = $1 AND status = 'prepared'
6532 ",
6533 )
6534 .bind(intent_id)
6535 .execute(&mut *transaction)
6536 .await
6537 .map_err(|e| anyhow::anyhow!("Failed to mark execution intent recoverable: {e}"))?;
6538 anyhow::ensure!(
6539 result.rows_affected() == 1,
6540 "Execution intent {intent_id} is not recoverable from preparation"
6541 );
6542 sqlx::query(
6543 "
6544 INSERT INTO execution_transaction_transition (
6545 intent_id, transition_key, from_status, to_status
6546 ) VALUES ($1, 'recoverable', $2, 'recoverable')
6547 ON CONFLICT (intent_id, transition_key) DO NOTHING
6548 ",
6549 )
6550 .bind(intent_id)
6551 .bind(current_status)
6552 .execute(&mut *transaction)
6553 .await
6554 .map_err(|e| anyhow::anyhow!("Failed to record recoverable transition: {e}"))?;
6555 transaction
6556 .commit()
6557 .await
6558 .map_err(|e| anyhow::anyhow!("Failed to commit recoverable transition: {e}"))?;
6559 Ok(())
6560 }
6561
6562 pub async fn add_execution_transaction_hash(
6569 &self,
6570 intent_id: i64,
6571 chain_id: u32,
6572 transaction_hash: &str,
6573 raw_transaction: &[u8],
6574 ) -> anyhow::Result<ExecutionTransactionHashRow> {
6575 self.add_execution_transaction_payload(
6576 intent_id,
6577 chain_id,
6578 transaction_hash,
6579 Some(raw_transaction),
6580 None,
6581 )
6582 .await
6583 }
6584
6585 pub(crate) async fn add_execution_transaction_envelope(
6586 &self,
6587 intent_id: i64,
6588 chain_id: u32,
6589 transaction_hash: &str,
6590 sealed_transaction: &[u8],
6591 ) -> anyhow::Result<ExecutionTransactionHashRow> {
6592 self.add_execution_transaction_payload(
6593 intent_id,
6594 chain_id,
6595 transaction_hash,
6596 None,
6597 Some(sealed_transaction),
6598 )
6599 .await
6600 }
6601
6602 async fn add_execution_transaction_payload(
6603 &self,
6604 intent_id: i64,
6605 chain_id: u32,
6606 transaction_hash: &str,
6607 raw_transaction: Option<&[u8]>,
6608 sealed_transaction: Option<&[u8]>,
6609 ) -> anyhow::Result<ExecutionTransactionHashRow> {
6610 anyhow::ensure!(
6611 raw_transaction.is_some() != sealed_transaction.is_some(),
6612 "A signed transaction requires exactly one payload representation"
6613 );
6614 let chain_id_db = i32::try_from(chain_id)
6615 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
6616 let mut transaction =
6617 self.pool.begin().await.map_err(|e| {
6618 anyhow::anyhow!("Failed to start signed transaction persistence: {e}")
6619 })?;
6620
6621 if let Some(envelope) = sealed_transaction {
6622 let state_row = sqlx::query(
6623 "SELECT deployment_id, protocol_version, operation, active_key_id \
6624 FROM execution_payload_state WHERE component = 'signed_transactions' FOR SHARE",
6625 )
6626 .fetch_optional(&mut *transaction)
6627 .await
6628 .context("failed to lock execution payload state for protected persistence")?
6629 .ok_or_else(|| anyhow::anyhow!("Execution payload protection is not active"))?;
6630 let state = execution_payload_state_from_row(&state_row)?;
6631 anyhow::ensure!(
6632 state.protocol_version == EXECUTION_PAYLOAD_PROTOCOL_VERSION
6633 && state.operation == "ready",
6634 "Execution payload storage is not ready for protected persistence"
6635 );
6636 anyhow::ensure!(
6637 envelope_key_id(envelope)?.as_slice() == state.active_key_id.as_slice(),
6638 "Signed transaction envelope does not use the database active key"
6639 );
6640 } else {
6641 let marker = sqlx::query_scalar::<_, bool>(
6642 "SELECT EXISTS (SELECT 1 FROM execution_schema_version WHERE component = $1)",
6643 )
6644 .bind(EXECUTION_PAYLOAD_COMPONENT)
6645 .fetch_one(&mut *transaction)
6646 .await
6647 .context("failed to inspect execution payload marker")?;
6648 anyhow::ensure!(
6649 !marker,
6650 "Plaintext signed transaction persistence is disabled after payload activation"
6651 );
6652 }
6653 let current_status = sqlx::query_scalar::<_, String>(
6654 "SELECT status FROM execution_intent WHERE id = $1 FOR UPDATE",
6655 )
6656 .bind(intent_id)
6657 .fetch_optional(&mut *transaction)
6658 .await
6659 .map_err(|e| anyhow::anyhow!("Failed to lock execution intent {intent_id}: {e}"))?
6660 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))?;
6661 anyhow::ensure!(
6662 current_status == TransactionStatus::Prepared.as_str()
6663 || current_status == TransactionStatus::Signed.as_str(),
6664 "Execution intent {intent_id} is {current_status}, not prepared for signing"
6665 );
6666
6667 let row = sqlx::query_as::<_, ExecutionTransactionHashRow>(
6668 "
6669 INSERT INTO execution_transaction_hash (
6670 intent_id, chain_id, transaction_hash, payload_expected,
6671 raw_transaction, sealed_transaction, status
6672 )
6673 SELECT id, chain_id, $3, TRUE, $4, $5, 'signed'
6674 FROM execution_intent
6675 WHERE id = $1 AND chain_id = $2 AND nonce IS NOT NULL
6676 ON CONFLICT (chain_id, transaction_hash) DO UPDATE
6677 SET transaction_hash = EXCLUDED.transaction_hash
6678 WHERE execution_transaction_hash.intent_id = EXCLUDED.intent_id
6679 AND execution_transaction_hash.payload_expected
6680 AND (
6681 (
6682 EXCLUDED.raw_transaction IS NOT NULL
6683 AND execution_transaction_hash.raw_transaction = EXCLUDED.raw_transaction
6684 AND execution_transaction_hash.sealed_transaction IS NULL
6685 )
6686 OR (
6687 EXCLUDED.sealed_transaction IS NOT NULL
6688 AND execution_transaction_hash.raw_transaction IS NULL
6689 AND execution_transaction_hash.sealed_transaction IS NOT NULL
6690 )
6691 )
6692 RETURNING
6693 id, intent_id, chain_id, transaction_hash, payload_expected,
6694 raw_transaction, sealed_transaction, status,
6695 block_number, block_hash, receipt_success, gas_used,
6696 effective_gas_price, current
6697 ",
6698 )
6699 .bind(intent_id)
6700 .bind(chain_id_db)
6701 .bind(transaction_hash)
6702 .bind(raw_transaction)
6703 .bind(sealed_transaction)
6704 .fetch_optional(&mut *transaction)
6705 .await
6706 .map_err(|e| {
6707 anyhow::anyhow!("Failed to persist signed transaction {transaction_hash}: {e}")
6708 })?
6709 .ok_or_else(|| {
6710 anyhow::anyhow!(
6711 "Signed transaction {transaction_hash} conflicts with its persisted identity"
6712 )
6713 })?;
6714
6715 sqlx::query(
6716 "UPDATE execution_intent SET status = 'signed', updated_at = NOW() WHERE id = $1",
6717 )
6718 .bind(intent_id)
6719 .execute(&mut *transaction)
6720 .await
6721 .map_err(|e| anyhow::anyhow!("Failed to mark execution intent signed: {e}"))?;
6722 sqlx::query(
6723 "
6724 INSERT INTO execution_transaction_transition (
6725 intent_id, transaction_hash_id, transition_key, from_status, to_status
6726 ) VALUES ($1, $2, $3, $4, 'signed')
6727 ON CONFLICT (intent_id, transition_key) DO NOTHING
6728 ",
6729 )
6730 .bind(intent_id)
6731 .bind(row.id)
6732 .bind(format!("signed:{transaction_hash}"))
6733 .bind(current_status)
6734 .execute(&mut *transaction)
6735 .await
6736 .map_err(|e| anyhow::anyhow!("Failed to record signed transaction transition: {e}"))?;
6737
6738 transaction
6739 .commit()
6740 .await
6741 .map_err(|e| anyhow::anyhow!("Failed to commit signed transaction: {e}"))?;
6742 Ok(row)
6743 }
6744
6745 #[expect(
6751 clippy::too_many_arguments,
6752 reason = "the parameters are the canonical receipt observation persisted atomically"
6753 )]
6754 pub async fn record_execution_status(
6755 &self,
6756 intent_id: i64,
6757 transaction_hash: &str,
6758 status: TransactionStatus,
6759 block_number: Option<u64>,
6760 block_hash: Option<&str>,
6761 receipt_success: Option<bool>,
6762 gas_used: Option<u64>,
6763 effective_gas_price: Option<&str>,
6764 ) -> anyhow::Result<()> {
6765 let block_number_db = block_number
6766 .map(i64::try_from)
6767 .transpose()
6768 .with_context(|| {
6769 format!(
6770 "Execution block number {} exceeds PostgreSQL BIGINT",
6771 block_number.unwrap_or_default()
6772 )
6773 })?;
6774 let gas_used_db = gas_used.map(i64::try_from).transpose().with_context(|| {
6775 format!(
6776 "Execution gas used {} exceeds PostgreSQL BIGINT",
6777 gas_used.unwrap_or_default()
6778 )
6779 })?;
6780 let mut transaction = self
6781 .pool
6782 .begin()
6783 .await
6784 .map_err(|e| anyhow::anyhow!("Failed to start execution status transition: {e}"))?;
6785 let (current_status, fill_emitted, terminal_emitted) =
6786 sqlx::query_as::<_, (String, bool, bool)>(
6787 "SELECT status, fill_emitted, terminal_emitted FROM execution_intent WHERE id = $1 FOR UPDATE",
6788 )
6789 .bind(intent_id)
6790 .fetch_optional(&mut *transaction)
6791 .await
6792 .map_err(|e| anyhow::anyhow!("Failed to lock execution intent {intent_id}: {e}"))?
6793 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))?;
6794 anyhow::ensure!(
6795 execution_transition_allowed(¤t_status, status),
6796 "Invalid execution transition for intent {intent_id}: {current_status} -> {}",
6797 status.as_str()
6798 );
6799
6800 let active = match status {
6801 TransactionStatus::Finalized | TransactionStatus::Reverted => {
6802 !fill_emitted && !terminal_emitted
6803 }
6804 TransactionStatus::Recoverable => false,
6805 _ => true,
6806 };
6807 let hash_result = sqlx::query(
6808 "
6809 UPDATE execution_transaction_hash
6810 SET status = $3,
6811 block_number = COALESCE($4, block_number),
6812 block_hash = COALESCE($5, block_hash),
6813 receipt_success = COALESCE($6, receipt_success),
6814 gas_used = COALESCE($7, gas_used),
6815 effective_gas_price = COALESCE($8, effective_gas_price),
6816 updated_at = NOW()
6817 WHERE intent_id = $1 AND transaction_hash = $2
6818 ",
6819 )
6820 .bind(intent_id)
6821 .bind(transaction_hash)
6822 .bind(status.as_str())
6823 .bind(block_number_db)
6824 .bind(block_hash)
6825 .bind(receipt_success)
6826 .bind(gas_used_db)
6827 .bind(effective_gas_price)
6828 .execute(&mut *transaction)
6829 .await
6830 .map_err(|e| anyhow::anyhow!("Failed to update execution hash {transaction_hash}: {e}"))?;
6831 anyhow::ensure!(
6832 hash_result.rows_affected() == 1,
6833 "Execution transaction hash {transaction_hash} was not found for intent {intent_id}"
6834 );
6835
6836 sqlx::query(
6837 "
6838 UPDATE execution_intent
6839 SET status = $2, active = $3, updated_at = NOW()
6840 WHERE id = $1
6841 ",
6842 )
6843 .bind(intent_id)
6844 .bind(status.as_str())
6845 .bind(active)
6846 .execute(&mut *transaction)
6847 .await
6848 .map_err(|e| anyhow::anyhow!("Failed to update execution intent {intent_id}: {e}"))?;
6849
6850 let transition_key = format!(
6851 "{}:{transaction_hash}:{}:{}",
6852 status.as_str(),
6853 block_number.map_or_else(|| "none".to_string(), |value| value.to_string()),
6854 block_hash.unwrap_or("none")
6855 );
6856 sqlx::query(
6857 "
6858 INSERT INTO execution_transaction_transition (
6859 intent_id, transaction_hash_id, transition_key, from_status, to_status,
6860 block_number, block_hash
6861 )
6862 SELECT $1, id, $3, $4, $5, $6, $7
6863 FROM execution_transaction_hash
6864 WHERE intent_id = $1 AND transaction_hash = $2
6865 ON CONFLICT (intent_id, transition_key) DO NOTHING
6866 ",
6867 )
6868 .bind(intent_id)
6869 .bind(transaction_hash)
6870 .bind(transition_key)
6871 .bind(current_status)
6872 .bind(status.as_str())
6873 .bind(block_number_db)
6874 .bind(block_hash)
6875 .execute(&mut *transaction)
6876 .await
6877 .map_err(|e| anyhow::anyhow!("Failed to record execution transition: {e}"))?;
6878
6879 transaction
6880 .commit()
6881 .await
6882 .map_err(|e| anyhow::anyhow!("Failed to commit execution transition: {e}"))?;
6883 Ok(())
6884 }
6885
6886 pub(crate) async fn record_execution_finality_verified(
6893 &self,
6894 finality: &ExecutionFinalityTransition<'_>,
6895 ) -> anyhow::Result<()> {
6896 anyhow::ensure!(
6897 matches!(
6898 finality.status,
6899 TransactionStatus::Finalized | TransactionStatus::Reverted
6900 ),
6901 "Verified finality requires a finalized or reverted status"
6902 );
6903 anyhow::ensure!(
6904 !finality.decisions.is_empty(),
6905 "Verified finality requires decision evidence"
6906 );
6907 anyhow::ensure!(
6908 !finality.finalized_headers.is_empty()
6909 && finality.finalized_headers.windows(2).all(|headers| {
6910 headers[1].number == headers[0].number.saturating_add(1)
6911 && headers[1].parent_hash == headers[0].hash
6912 })
6913 && finality
6914 .finalized_headers
6915 .last()
6916 .is_some_and(|header| header.number >= finality.block_number),
6917 "Verified finality headers must form a continuous chain through the inclusion height"
6918 );
6919 let chain_id = i32::try_from(finality.chain_id)
6920 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
6921 let nonce =
6922 i64::try_from(finality.nonce).context("Execution nonce exceeds PostgreSQL BIGINT")?;
6923 let next_nonce = nonce
6924 .checked_add(1)
6925 .ok_or_else(|| anyhow::anyhow!("Canonical nonce overflow"))?;
6926 let block_number = i64::try_from(finality.block_number)
6927 .context("Finality block exceeds PostgreSQL BIGINT")?;
6928 let gas_used = i64::try_from(finality.gas_used)
6929 .context("Finality gas used exceeds PostgreSQL BIGINT")?;
6930 let mut transaction =
6931 self.pool.begin().await.map_err(|e| {
6932 anyhow::anyhow!("Failed to start verified finality transition: {e}")
6933 })?;
6934 let (manifest_version, manifest_digest, stored_nonce, revision) =
6935 sqlx::query_as::<_, (String, String, i64, i64)>(
6936 "
6937 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
6938 FROM execution_verification_nonce
6939 WHERE chain_id = $1 AND wallet_address = $2
6940 FOR UPDATE
6941 ",
6942 )
6943 .bind(chain_id)
6944 .bind(finality.wallet_address)
6945 .fetch_optional(&mut *transaction)
6946 .await
6947 .map_err(|e| anyhow::anyhow!("Failed to lock finality nonce ledger: {e}"))?
6948 .ok_or_else(|| anyhow::anyhow!("Canonical nonce ledger is not initialized"))?;
6949 anyhow::ensure!(
6950 manifest_version == finality.manifest_version
6951 && manifest_digest == finality.manifest_digest,
6952 "Verified finality manifest identity changed"
6953 );
6954 anyhow::ensure!(
6955 stored_nonce == nonce,
6956 "Finalized nonce {} does not match canonical nonce {stored_nonce}",
6957 finality.nonce
6958 );
6959 let stored_tip = sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
6960 "
6961 SELECT number, hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
6962 FROM execution_verified_finalized_header
6963 WHERE chain_id = $1 AND wallet_address = $2
6964 ORDER BY number DESC
6965 LIMIT 1
6966 ",
6967 )
6968 .bind(chain_id)
6969 .bind(finality.wallet_address)
6970 .fetch_one(&mut *transaction)
6971 .await
6972 .map_err(|e| anyhow::anyhow!("Failed to lock finalized header tip: {e}"))?;
6973 let first_header = &finality.finalized_headers[0];
6974 anyhow::ensure!(
6975 stored_tip
6976 == (
6977 i64::try_from(first_header.number)
6978 .context("Verified finalized height exceeds PostgreSQL BIGINT")?,
6979 first_header.hash.clone(),
6980 first_header.parent_hash.clone(),
6981 i64::try_from(first_header.timestamp)
6982 .context("Verified finalized timestamp exceeds PostgreSQL BIGINT")?,
6983 first_header.base_fee_per_gas.map(|value| value.to_string()),
6984 finality.manifest_digest.to_string(),
6985 ),
6986 "Verified finality extension does not start at the durable tip"
6987 );
6988
6989 for header in finality.finalized_headers.iter().skip(1) {
6990 let number = i64::try_from(header.number)
6991 .context("Verified finalized height exceeds PostgreSQL BIGINT")?;
6992 let timestamp = i64::try_from(header.timestamp)
6993 .context("Verified finalized timestamp exceeds PostgreSQL BIGINT")?;
6994 let base_fee = header.base_fee_per_gas.map(|value| value.to_string());
6995 sqlx::query(
6996 "
6997 INSERT INTO execution_verified_finalized_header (
6998 chain_id, wallet_address, number, hash, parent_hash, timestamp,
6999 base_fee_per_gas, manifest_digest
7000 )
7001 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
7002 ON CONFLICT (chain_id, wallet_address, number) DO NOTHING
7003 ",
7004 )
7005 .bind(chain_id)
7006 .bind(finality.wallet_address)
7007 .bind(number)
7008 .bind(&header.hash)
7009 .bind(&header.parent_hash)
7010 .bind(timestamp)
7011 .bind(&base_fee)
7012 .bind(finality.manifest_digest)
7013 .execute(&mut *transaction)
7014 .await
7015 .map_err(|e| anyhow::anyhow!("Failed to extend finalized header ledger: {e}"))?;
7016 let stored = sqlx::query_as::<_, (String, String, i64, Option<String>, String)>(
7017 "
7018 SELECT hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
7019 FROM execution_verified_finalized_header
7020 WHERE chain_id = $1 AND wallet_address = $2 AND number = $3
7021 ",
7022 )
7023 .bind(chain_id)
7024 .bind(finality.wallet_address)
7025 .bind(number)
7026 .fetch_one(&mut *transaction)
7027 .await
7028 .map_err(|e| anyhow::anyhow!("Failed to validate finalized header ledger: {e}"))?;
7029 anyhow::ensure!(
7030 stored
7031 == (
7032 header.hash.clone(),
7033 header.parent_hash.clone(),
7034 timestamp,
7035 base_fee,
7036 finality.manifest_digest.to_string(),
7037 ),
7038 "Finalized header ledger conflicts at height {}",
7039 header.number
7040 );
7041 }
7042
7043 let (current_status, intent_nonce, fill_emitted, terminal_emitted) =
7044 sqlx::query_as::<_, (String, Option<i64>, bool, bool)>(
7045 "
7046 SELECT status, nonce, fill_emitted, terminal_emitted
7047 FROM execution_intent
7048 WHERE id = $1 AND chain_id = $2 AND wallet_address = $3 AND active
7049 FOR UPDATE
7050 ",
7051 )
7052 .bind(finality.intent_id)
7053 .bind(chain_id)
7054 .bind(finality.wallet_address)
7055 .fetch_optional(&mut *transaction)
7056 .await
7057 .map_err(|e| anyhow::anyhow!("Failed to lock intent for verified finality: {e}"))?
7058 .ok_or_else(|| anyhow::anyhow!("Active finality intent was not found"))?;
7059 anyhow::ensure!(
7060 intent_nonce == Some(nonce)
7061 && execution_transition_allowed(¤t_status, finality.status),
7062 "Intent cannot make the verified finality transition"
7063 );
7064
7065 for (index, decision) in finality.decisions.iter().enumerate() {
7066 let height_start = decision
7067 .height_start
7068 .map(i64::try_from)
7069 .transpose()
7070 .context("Verification height exceeds PostgreSQL BIGINT")?;
7071 let height_end = decision
7072 .height_end
7073 .map(i64::try_from)
7074 .transpose()
7075 .context("Verification height exceeds PostgreSQL BIGINT")?;
7076 let transition_key = format!(
7077 "finality:{}:{}:{index}",
7078 finality.intent_id, decision.read_class
7079 );
7080 sqlx::query(
7081 "
7082 INSERT INTO execution_verification_decision (
7083 intent_id, nonce, decision_class, read_class, height_start, height_end,
7084 manifest_version, manifest_digest, provider_ids, operator_ids,
7085 failure_domain_ids, response_class, normalized_value_digest,
7086 nonce_revision, outcome, transition_key
7087 )
7088 VALUES (
7089 $1, $2, 'finality', $3, $4, $5, $6, $7, $8, $9, $10,
7090 'all_valid', $11, $12, 'verified', $13
7091 )
7092 ",
7093 )
7094 .bind(finality.intent_id)
7095 .bind(nonce)
7096 .bind(decision.read_class)
7097 .bind(height_start)
7098 .bind(height_end)
7099 .bind(finality.manifest_version)
7100 .bind(finality.manifest_digest)
7101 .bind(finality.provider_ids)
7102 .bind(finality.operator_ids)
7103 .bind(finality.failure_domain_ids)
7104 .bind(&decision.normalized_value_digest)
7105 .bind(revision)
7106 .bind(transition_key)
7107 .execute(&mut *transaction)
7108 .await
7109 .map_err(|e| anyhow::anyhow!("Failed to persist finality verification: {e}"))?;
7110 }
7111
7112 let hash_result = sqlx::query(
7113 "
7114 UPDATE execution_transaction_hash
7115 SET status = $3, block_number = $4, block_hash = $5,
7116 receipt_success = $6, gas_used = $7, effective_gas_price = $8,
7117 updated_at = NOW()
7118 WHERE intent_id = $1 AND transaction_hash = $2
7119 ",
7120 )
7121 .bind(finality.intent_id)
7122 .bind(finality.transaction_hash)
7123 .bind(finality.status.as_str())
7124 .bind(block_number)
7125 .bind(finality.block_hash)
7126 .bind(finality.receipt_success)
7127 .bind(gas_used)
7128 .bind(finality.effective_gas_price)
7129 .execute(&mut *transaction)
7130 .await
7131 .map_err(|e| anyhow::anyhow!("Failed to record verified finality receipt: {e}"))?;
7132 anyhow::ensure!(
7133 hash_result.rows_affected() == 1,
7134 "Finality transaction hash was not found"
7135 );
7136
7137 let active = !fill_emitted && !terminal_emitted;
7138 sqlx::query(
7139 "UPDATE execution_intent SET status = $2, active = $3, updated_at = NOW() WHERE id = $1",
7140 )
7141 .bind(finality.intent_id)
7142 .bind(finality.status.as_str())
7143 .bind(active)
7144 .execute(&mut *transaction)
7145 .await
7146 .map_err(|e| anyhow::anyhow!("Failed to record verified finality intent: {e}"))?;
7147
7148 let transition_key = format!(
7149 "{}:{}:{}:{}",
7150 finality.status.as_str(),
7151 finality.transaction_hash,
7152 finality.block_number,
7153 finality.block_hash
7154 );
7155 sqlx::query(
7156 "
7157 INSERT INTO execution_transaction_transition (
7158 intent_id, transaction_hash_id, transition_key, from_status, to_status,
7159 block_number, block_hash
7160 )
7161 SELECT $1, id, $3, $4, $5, $6, $7
7162 FROM execution_transaction_hash
7163 WHERE intent_id = $1 AND transaction_hash = $2
7164 ",
7165 )
7166 .bind(finality.intent_id)
7167 .bind(finality.transaction_hash)
7168 .bind(transition_key)
7169 .bind(current_status)
7170 .bind(finality.status.as_str())
7171 .bind(block_number)
7172 .bind(finality.block_hash)
7173 .execute(&mut *transaction)
7174 .await
7175 .map_err(|e| anyhow::anyhow!("Failed to record verified finality transition: {e}"))?;
7176
7177 let nonce_result = sqlx::query(
7178 "
7179 UPDATE execution_verification_nonce
7180 SET next_canonical_nonce = $3, revision = revision + 1, updated_at = NOW()
7181 WHERE chain_id = $1 AND wallet_address = $2
7182 AND next_canonical_nonce = $4 AND revision = $5
7183 ",
7184 )
7185 .bind(chain_id)
7186 .bind(finality.wallet_address)
7187 .bind(next_nonce)
7188 .bind(nonce)
7189 .bind(revision)
7190 .execute(&mut *transaction)
7191 .await
7192 .map_err(|e| anyhow::anyhow!("Failed to advance canonical nonce ledger: {e}"))?;
7193 anyhow::ensure!(
7194 nonce_result.rows_affected() == 1,
7195 "Canonical nonce ledger changed during finality transition"
7196 );
7197 transaction
7198 .commit()
7199 .await
7200 .map_err(|e| anyhow::anyhow!("Failed to commit verified finality transition: {e}"))?;
7201 Ok(())
7202 }
7203
7204 pub(crate) async fn get_execution_intent(
7206 &self,
7207 intent_id: i64,
7208 ) -> anyhow::Result<ExecutionIntentRow> {
7209 sqlx::query_as::<_, ExecutionIntentRow>(
7210 "
7211 SELECT
7212 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
7213 client_order_id, trader_id, strategy_id, account_id, instrument_id,
7214 pool_address, transaction_to, transaction_input, transaction_value,
7215 amount_in, created_block, acknowledgement_emitted, fill_emitted,
7216 terminal_emitted, active
7217 FROM execution_intent
7218 WHERE id = $1
7219 ",
7220 )
7221 .bind(intent_id)
7222 .fetch_optional(&self.pool)
7223 .await
7224 .context("failed to load execution intent")?
7225 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))
7226 }
7227
7228 pub async fn get_active_execution_intent(
7234 &self,
7235 chain_id: u32,
7236 wallet_address: &str,
7237 ) -> anyhow::Result<Option<ExecutionIntentRow>> {
7238 let chain_id_db = i32::try_from(chain_id)
7239 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
7240 let mut transaction = self
7241 .pool
7242 .begin_with("BEGIN ISOLATION LEVEL READ COMMITTED")
7243 .await
7244 .context("failed to start active execution intent reconciliation")?;
7245 Self::lock_execution_signer(&mut transaction, chain_id, wallet_address).await?;
7246 let intent = sqlx::query_as::<_, ExecutionIntentRow>(
7247 "
7248 SELECT
7249 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
7250 client_order_id, trader_id, strategy_id, account_id, instrument_id,
7251 pool_address, transaction_to, transaction_input, transaction_value,
7252 amount_in, created_block, acknowledgement_emitted, fill_emitted,
7253 terminal_emitted, active
7254 FROM execution_intent
7255 WHERE chain_id = $1 AND wallet_address = $2 AND active
7256 ",
7257 )
7258 .bind(chain_id_db)
7259 .bind(wallet_address)
7260 .fetch_optional(&mut *transaction)
7261 .await
7262 .context("failed to load active execution intent")?;
7263 transaction
7264 .commit()
7265 .await
7266 .context("failed to complete active execution intent reconciliation")?;
7267 Ok(intent)
7268 }
7269
7270 async fn lock_execution_signer(
7271 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
7272 chain_id: u32,
7273 wallet_address: &str,
7274 ) -> anyhow::Result<()> {
7275 let lock = PgAdvisoryLock::new(format!(
7276 "nautilus:blockchain:execution:{chain_id}:{}",
7277 wallet_address.to_ascii_lowercase()
7278 ));
7279 let PgAdvisoryLockKey::BigInt(lock_key) = lock.key() else {
7280 unreachable!("string advisory locks use the 64-bit key space");
7281 };
7282
7283 sqlx::query("SELECT pg_advisory_xact_lock($1)")
7286 .bind(*lock_key)
7287 .execute(&mut **transaction)
7288 .await
7289 .context("failed to acquire execution signer reservation fence")?;
7290 Ok(())
7291 }
7292
7293 pub async fn has_recoverable_signed_execution(
7299 &self,
7300 chain_id: u32,
7301 wallet_address: &str,
7302 ) -> anyhow::Result<bool> {
7303 let chain_id_db = i32::try_from(chain_id)
7304 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
7305 sqlx::query_scalar::<_, bool>(
7306 "
7307 SELECT EXISTS (
7308 SELECT 1
7309 FROM execution_intent AS intent
7310 JOIN execution_transaction_hash AS hash ON hash.intent_id = intent.id
7311 WHERE intent.chain_id = $1
7312 AND intent.wallet_address = $2
7313 AND intent.status = 'recoverable'
7314 AND (hash.raw_transaction IS NOT NULL OR hash.sealed_transaction IS NOT NULL)
7315 )
7316 ",
7317 )
7318 .bind(chain_id_db)
7319 .bind(wallet_address)
7320 .fetch_one(&self.pool)
7321 .await
7322 .map_err(|e| anyhow::anyhow!("Failed to inspect recoverable signed executions: {e}"))
7323 }
7324
7325 pub async fn get_execution_transaction_hashes(
7331 &self,
7332 intent_id: i64,
7333 ) -> anyhow::Result<Vec<ExecutionTransactionHashRow>> {
7334 sqlx::query_as::<_, ExecutionTransactionHashRow>(
7335 "
7336 SELECT
7337 id, intent_id, chain_id, transaction_hash, payload_expected,
7338 raw_transaction, sealed_transaction, status,
7339 block_number, block_hash, receipt_success, gas_used,
7340 effective_gas_price, current
7341 FROM execution_transaction_hash
7342 WHERE intent_id = $1
7343 ORDER BY id
7344 ",
7345 )
7346 .bind(intent_id)
7347 .fetch_all(&self.pool)
7348 .await
7349 .map_err(|e| anyhow::anyhow!("Failed to load execution transaction hashes: {e}"))
7350 }
7351
7352 pub async fn mark_execution_event_emitted(
7359 &self,
7360 intent_id: i64,
7361 event: &str,
7362 ) -> anyhow::Result<()> {
7363 let statement = match event {
7364 "acknowledgement" => {
7365 "UPDATE execution_intent SET acknowledgement_emitted = TRUE, updated_at = NOW() WHERE id = $1"
7366 }
7367 "fill" => {
7368 "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"
7369 }
7370 "terminal" => {
7371 "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"
7372 }
7373 _ => anyhow::bail!("Unknown execution event marker {event}"),
7374 };
7375 let result = sqlx::query(statement)
7376 .bind(intent_id)
7377 .execute(&self.pool)
7378 .await
7379 .map_err(|e| anyhow::anyhow!("Failed to mark execution {event} emitted: {e}"))?;
7380 anyhow::ensure!(
7381 result.rows_affected() == 1,
7382 "Execution intent {intent_id} cannot mark {event} emitted"
7383 );
7384 Ok(())
7385 }
7386
7387 pub async fn update_execution_transaction_status(
7393 &self,
7394 chain_id: u32,
7395 transaction_hash: &str,
7396 status: &str,
7397 ) -> anyhow::Result<()> {
7398 let result = sqlx::query(
7399 "
7400 UPDATE execution_transaction
7401 SET status = $3
7402 WHERE chain_id = $1 AND transaction_hash = $2
7403 ",
7404 )
7405 .bind(chain_id as i32)
7406 .bind(transaction_hash)
7407 .bind(status)
7408 .execute(&self.pool)
7409 .await
7410 .map_err(|e| anyhow::anyhow!("Failed to update execution_transaction table: {e}"))?;
7411
7412 anyhow::ensure!(
7413 result.rows_affected() == 1,
7414 "Execution transaction {transaction_hash} was not found for status update"
7415 );
7416 Ok(())
7417 }
7418
7419 pub async fn get_execution_transaction(
7425 &self,
7426 chain_id: u32,
7427 transaction_hash: &str,
7428 ) -> anyhow::Result<Option<ExecutionTransactionRow>> {
7429 let chain_id_db = i32::try_from(chain_id)
7430 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
7431 sqlx::query_as::<_, ExecutionTransactionRow>(
7432 "
7433 SELECT wallet_address, nonce, transaction_hash, purpose, status, client_order_id
7434 FROM (
7435 SELECT
7436 intent.wallet_address,
7437 intent.nonce,
7438 hash.transaction_hash,
7439 intent.purpose,
7440 intent.status,
7441 intent.client_order_id,
7442 0 AS source_priority
7443 FROM execution_transaction_hash AS hash
7444 JOIN execution_intent AS intent ON intent.id = hash.intent_id
7445 WHERE hash.chain_id = $1 AND hash.transaction_hash = $2
7446 UNION ALL
7447 SELECT
7448 wallet_address,
7449 nonce,
7450 transaction_hash,
7451 purpose,
7452 status,
7453 client_order_id,
7454 1 AS source_priority
7455 FROM execution_transaction
7456 WHERE chain_id = $1 AND transaction_hash = $2
7457 ) AS record
7458 ORDER BY source_priority
7459 LIMIT 1
7460 ",
7461 )
7462 .bind(chain_id_db)
7463 .bind(transaction_hash)
7464 .fetch_optional(&self.pool)
7465 .await
7466 .map_err(|e| anyhow::anyhow!("Failed to load from execution_transaction table: {e}"))
7467 }
7468}
7469
7470fn execution_payload_state_from_row(
7471 row: &sqlx::postgres::PgRow,
7472) -> anyhow::Result<ExecutionPayloadState> {
7473 Ok(ExecutionPayloadState {
7474 deployment_id: row.try_get("deployment_id")?,
7475 protocol_version: row.try_get("protocol_version")?,
7476 operation: row.try_get("operation")?,
7477 active_key_id: row.try_get("active_key_id")?,
7478 })
7479}
7480
7481fn validate_execution_payload_state(
7482 state: &ExecutionPayloadState,
7483 keys: &PayloadKeySet,
7484) -> anyhow::Result<()> {
7485 anyhow::ensure!(
7486 state.protocol_version == EXECUTION_PAYLOAD_PROTOCOL_VERSION,
7487 "Execution payload protocol version {} is not supported",
7488 state.protocol_version
7489 );
7490 anyhow::ensure!(
7491 state.deployment_id == keys.deployment_id(),
7492 "Execution payload deployment ID does not match this database"
7493 );
7494 anyhow::ensure!(
7495 state.active_key_id.as_slice() == keys.active_key_id(),
7496 "Configured active payload key does not match the database active key"
7497 );
7498 Ok(())
7499}
7500
7501async fn lock_execution_payload_operation(
7502 transaction: &mut Transaction<'_, Postgres>,
7503) -> anyhow::Result<()> {
7504 let lock = PgAdvisoryLock::new("nautilus:blockchain:execution-payload");
7505 let PgAdvisoryLockKey::BigInt(lock_key) = lock.key() else {
7506 unreachable!("string advisory locks use the 64-bit key space");
7507 };
7508 sqlx::query("SELECT pg_advisory_xact_lock($1)")
7509 .bind(*lock_key)
7510 .execute(&mut **transaction)
7511 .await
7512 .context("failed to acquire execution payload operation fence")?;
7513 Ok(())
7514}
7515
7516async fn reserve_execution_payload_seal(
7517 transaction: &mut Transaction<'_, Postgres>,
7518 key_id: &[u8; 32],
7519) -> anyhow::Result<()> {
7520 let seals = sqlx::query_scalar::<_, i64>(
7521 "INSERT INTO execution_payload_key_state (key_id, seals) VALUES ($1, 1) \
7522 ON CONFLICT (key_id) DO UPDATE SET seals = execution_payload_key_state.seals + 1 \
7523 WHERE execution_payload_key_state.seals < $2 \
7524 RETURNING seals",
7525 )
7526 .bind(key_id.as_slice())
7527 .bind(EXECUTION_PAYLOAD_MAX_SEALS - 1)
7528 .fetch_optional(&mut **transaction)
7529 .await
7530 .context("failed to reserve execution payload nonce use")?;
7531 anyhow::ensure!(
7532 seals.is_some(),
7533 "Execution payload key reached its seal limit; rotate the active key before continuing"
7534 );
7535 Ok(())
7536}
7537
7538async fn validate_execution_payload_key_inventory(
7539 transaction: &mut Transaction<'_, Postgres>,
7540 keys: &PayloadKeySet,
7541) -> anyhow::Result<()> {
7542 let envelopes = sqlx::query_scalar::<_, Vec<u8>>(
7543 "SELECT DISTINCT substring(sealed_transaction FROM 1 FOR 33) \
7544 FROM execution_transaction_hash \
7545 WHERE sealed_transaction IS NOT NULL",
7546 )
7547 .fetch_all(&mut **transaction)
7548 .await
7549 .context("failed to inspect execution payload key inventory")?;
7550
7551 for header in envelopes {
7552 anyhow::ensure!(
7553 header.len() == 33,
7554 "Stored execution payload has a truncated envelope header"
7555 );
7556 let mut envelope = header;
7557 envelope.extend_from_slice(&[0; 12 + 16]);
7558 let key_id = envelope_key_id(&envelope)?;
7559 anyhow::ensure!(
7560 keys.contains_key(&key_id),
7561 "Stored execution payload requires unavailable key {}",
7562 alloy::hex::encode(key_id)
7563 );
7564 }
7565 Ok(())
7566}
7567
7568async fn load_execution_intent(
7569 transaction: &mut Transaction<'_, Postgres>,
7570 intent_id: i64,
7571) -> anyhow::Result<ExecutionIntentRow> {
7572 sqlx::query_as::<_, ExecutionIntentRow>(
7573 "
7574 SELECT
7575 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
7576 client_order_id, trader_id, strategy_id, account_id, instrument_id,
7577 pool_address, transaction_to, transaction_input, transaction_value,
7578 amount_in, created_block, acknowledgement_emitted, fill_emitted,
7579 terminal_emitted, active
7580 FROM execution_intent
7581 WHERE id = $1
7582 FOR SHARE
7583 ",
7584 )
7585 .bind(intent_id)
7586 .fetch_optional(&mut **transaction)
7587 .await
7588 .context("failed to load execution intent for payload authentication")?
7589 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))
7590}
7591
7592fn execution_transition_allowed(current: &str, next: TransactionStatus) -> bool {
7593 if current == next.as_str() {
7594 return true;
7595 }
7596
7597 match current {
7598 "prepared" => matches!(
7599 next,
7600 TransactionStatus::Signed | TransactionStatus::Recoverable
7601 ),
7602 "signed" | "broadcast" => matches!(
7603 next,
7604 TransactionStatus::Broadcast
7605 | TransactionStatus::Included
7606 | TransactionStatus::Finalized
7607 | TransactionStatus::Reverted
7608 | TransactionStatus::Replaced
7609 | TransactionStatus::Dropped
7610 | TransactionStatus::Reorged
7611 ),
7612 "included" => matches!(
7613 next,
7614 TransactionStatus::Finalized
7615 | TransactionStatus::Reverted
7616 | TransactionStatus::Reorged
7617 | TransactionStatus::Replaced
7618 | TransactionStatus::Dropped
7619 ),
7620 "replaced" | "dropped" | "reorged" => matches!(
7621 next,
7622 TransactionStatus::Included
7623 | TransactionStatus::Finalized
7624 | TransactionStatus::Reverted
7625 | TransactionStatus::Replaced
7626 | TransactionStatus::Dropped
7627 | TransactionStatus::Reorged
7628 ),
7629 "finalized" | "reverted" | "recoverable" => false,
7630 _ => false,
7631 }
7632}
7633
7634#[cfg(test)]
7635pub(crate) mod tests {
7636 use std::time::Duration;
7637
7638 use anyhow::Context;
7639 use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
7640
7641 use super::{BlockchainCacheDatabase, ExecutionVerifiedHeader};
7642 use crate::rpc::verification::VERIFICATION_SCHEMA_VERSION;
7643
7644 pub(crate) struct ExecutionVerificationResume {
7645 pub next_canonical_nonce: u64,
7646 pub revision: u64,
7647 pub finalized_headers: Vec<ExecutionVerifiedHeader>,
7648 }
7649
7650 pub(crate) async fn connect_test_database(
7651 pg_options: PgConnectOptions,
7652 ) -> anyhow::Result<BlockchainCacheDatabase> {
7653 let pool = PgPoolOptions::new()
7654 .max_connections(32)
7655 .min_connections(1)
7656 .acquire_timeout(Duration::from_secs(3))
7657 .connect_with(pg_options)
7658 .await?;
7659 Ok(BlockchainCacheDatabase { pool })
7660 }
7661
7662 impl BlockchainCacheDatabase {
7663 pub(crate) async fn load_execution_verification_resume(
7665 &self,
7666 chain_id: u32,
7667 wallet_address: &str,
7668 manifest_version: &str,
7669 manifest_digest: &str,
7670 ) -> anyhow::Result<Option<ExecutionVerificationResume>> {
7671 let installed = sqlx::query_scalar::<_, i16>(
7672 "SELECT version FROM execution_schema_version \
7673 WHERE component = 'evm_execution_verification'",
7674 )
7675 .fetch_optional(&self.pool)
7676 .await
7677 .context("failed to inspect execution verification schema")?;
7678 let Some(installed) = installed else {
7679 return Ok(None);
7680 };
7681 anyhow::ensure!(
7682 installed <= VERIFICATION_SCHEMA_VERSION,
7683 "Unsupported execution verification schema version {installed}"
7684 );
7685 let chain_id = i32::try_from(chain_id)
7686 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
7687 let current = sqlx::query_as::<_, (String, String, i64, i64)>(
7688 "
7689 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
7690 FROM execution_verification_nonce
7691 WHERE chain_id = $1 AND wallet_address = $2
7692 ",
7693 )
7694 .bind(chain_id)
7695 .bind(wallet_address)
7696 .fetch_optional(&self.pool)
7697 .await
7698 .context("failed to load execution verification nonce position")?;
7699 let Some((stored_version, stored_digest, nonce, revision)) = current else {
7700 return Ok(None);
7701 };
7702 anyhow::ensure!(
7703 stored_version == manifest_version && stored_digest == manifest_digest,
7704 "Execution verification manifest identity changed"
7705 );
7706 let rows = sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
7707 "
7708 SELECT number, hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
7709 FROM execution_verified_finalized_header
7710 WHERE chain_id = $1 AND wallet_address = $2
7711 ORDER BY number
7712 ",
7713 )
7714 .bind(chain_id)
7715 .bind(wallet_address)
7716 .fetch_all(&self.pool)
7717 .await
7718 .context("failed to load verified finalized header ledger")?;
7719 let finalized_headers = rows
7720 .into_iter()
7721 .map(|(number, hash, parent_hash, timestamp, base_fee, digest)| {
7722 anyhow::ensure!(
7723 digest == manifest_digest,
7724 "Finalized header manifest identity changed"
7725 );
7726 Ok(ExecutionVerifiedHeader {
7727 number: u64::try_from(number)
7728 .context("Finalized header number is negative")?,
7729 hash,
7730 parent_hash,
7731 timestamp: u64::try_from(timestamp)
7732 .context("Finalized header timestamp is negative")?,
7733 base_fee_per_gas: base_fee
7734 .map(|value| {
7735 value.parse::<u128>().map_err(|_| {
7736 anyhow::anyhow!("Finalized header base fee is invalid")
7737 })
7738 })
7739 .transpose()?,
7740 })
7741 })
7742 .collect::<anyhow::Result<Vec<_>>>()?;
7743 anyhow::ensure!(
7744 !finalized_headers.is_empty()
7745 && finalized_headers.windows(2).all(|headers| {
7746 headers[1].number == headers[0].number.saturating_add(1)
7747 && headers[1].parent_hash == headers[0].hash
7748 }),
7749 "Verified finalized header ledger is not continuous"
7750 );
7751 Ok(Some(ExecutionVerificationResume {
7752 next_canonical_nonce: u64::try_from(nonce)
7753 .context("Canonical nonce is negative")?,
7754 revision: u64::try_from(revision)
7755 .context("Canonical nonce revision is negative")?,
7756 finalized_headers,
7757 }))
7758 }
7759 }
7760
7761 #[tokio::test]
7762 async fn connect_returns_err_for_unreachable_database() {
7763 let options = PgConnectOptions::new()
7766 .host("127.0.0.1")
7767 .port(1)
7768 .username("nautilus")
7769 .password("pass")
7770 .database("nautilus");
7771
7772 let result = BlockchainCacheDatabase::connect(options).await;
7773
7774 assert!(result.is_err());
7775 }
7776}