1use std::{collections::BTreeSet, fmt::Display, pin::Pin};
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 chain.clone(),
1310 token_row.address,
1311 token_row.name,
1312 token_row.symbol,
1313 token_row.decimals,
1314 )
1315 })
1316 .collect::<Vec<_>>()
1317 })
1318 .map_err(|e| anyhow::anyhow!("Failed to load tokens: {e}"))
1319 }
1320
1321 pub async fn load_invalid_token_addresses(
1327 &self,
1328 chain_id: u32,
1329 ) -> anyhow::Result<Vec<Address>> {
1330 sqlx::query_as::<_, (String,)>(
1331 "SELECT address FROM token WHERE chain_id = $1 AND error IS NOT NULL",
1332 )
1333 .bind(chain_id as i32)
1334 .fetch_all(&self.pool)
1335 .await?
1336 .into_iter()
1337 .map(|(address,)| validate_address(&address))
1338 .collect::<Result<Vec<_>, _>>()
1339 .map_err(|e| anyhow::anyhow!("Failed to load invalid token addresses: {e}"))
1340 }
1341
1342 pub async fn load_pools(
1348 &self,
1349 chain: SharedChain,
1350 dex_id: &str,
1351 ) -> anyhow::Result<Vec<PoolRow>> {
1352 sqlx::query_as::<_, PoolRow>(AssertSqlSafe(format!(
1353 "SELECT {POOL_ROW_COLUMNS} FROM pool WHERE chain_id = $1 AND dex_name = $2 ORDER BY creation_block ASC"
1354 )))
1355 .bind(chain.chain_id as i32)
1356 .bind(dex_id)
1357 .fetch_all(&self.pool)
1358 .await
1359 .map_err(|e| anyhow::anyhow!("Failed to load pools: {e}"))
1360 }
1361
1362 pub async fn load_pool(
1373 &self,
1374 chain: SharedChain,
1375 dex_id: &str,
1376 pool_identifier: &PoolIdentifier,
1377 ) -> anyhow::Result<Option<PoolRow>> {
1378 sqlx::query_as::<_, PoolRow>(AssertSqlSafe(format!(
1379 "SELECT {POOL_ROW_COLUMNS} FROM pool WHERE chain_id = $1 AND dex_name = $2 AND pool_identifier = $3"
1380 )))
1381 .bind(chain.chain_id as i32)
1382 .bind(dex_id)
1383 .bind(pool_identifier.as_ref())
1384 .fetch_optional(&self.pool)
1385 .await
1386 .map_err(|e| anyhow::anyhow!("Failed to load pool {pool_identifier}: {e}"))
1387 }
1388
1389 pub async fn toggle_perf_sync_settings(&self, enable: bool) -> anyhow::Result<()> {
1403 if enable {
1404 log::debug!("Enabling performance sync settings for bulk operations");
1405
1406 sqlx::query("SET synchronous_commit = OFF")
1408 .execute(&self.pool)
1409 .await
1410 .map_err(|e| anyhow::anyhow!("Failed to set synchronous_commit OFF: {e}"))?;
1411
1412 sqlx::query("SET work_mem = '256MB'")
1414 .execute(&self.pool)
1415 .await
1416 .map_err(|e| anyhow::anyhow!("Failed to set work_mem: {e}"))?;
1417
1418 log::debug!("Performance settings enabled: synchronous_commit=OFF, work_mem=256MB");
1419 } else {
1420 log::debug!("Restoring default safe database performance settings");
1421
1422 sqlx::query("SET synchronous_commit = ON")
1424 .execute(&self.pool)
1425 .await
1426 .map_err(|e| anyhow::anyhow!("Failed to set synchronous_commit ON: {e}"))?;
1427
1428 sqlx::query("RESET work_mem")
1430 .execute(&self.pool)
1431 .await
1432 .map_err(|e| anyhow::anyhow!("Failed to reset work_mem: {e}"))?;
1433 }
1434
1435 Ok(())
1436 }
1437
1438 pub async fn update_dex_last_synced_block(
1444 &self,
1445 chain_id: u32,
1446 dex: &DexType,
1447 block_number: u64,
1448 ) -> anyhow::Result<()> {
1449 sqlx::query(
1450 "
1451 UPDATE dex
1452 SET last_full_sync_pools_block_number = $3
1453 WHERE chain_id = $1 AND name = $2
1454 ",
1455 )
1456 .bind(chain_id as i32)
1457 .bind(dex.to_string())
1458 .bind(block_number as i64)
1459 .execute(&self.pool)
1460 .await
1461 .map(|_| ())
1462 .map_err(|e| anyhow::anyhow!("Failed to update dex last synced block: {e}"))
1463 }
1464
1465 pub async fn update_pool_last_synced_block(
1471 &self,
1472 chain_id: u32,
1473 dex: &DexType,
1474 pool_identifier: &PoolIdentifier,
1475 block_number: u64,
1476 ) -> anyhow::Result<()> {
1477 sqlx::query(
1478 "
1479 UPDATE pool
1480 SET last_full_sync_block_number = $4
1481 WHERE chain_id = $1
1482 AND dex_name = $2
1483 AND pool_identifier = $3
1484 ",
1485 )
1486 .bind(chain_id as i32)
1487 .bind(dex.to_string())
1488 .bind(pool_identifier.as_ref())
1489 .bind(block_number as i64)
1490 .execute(&self.pool)
1491 .await
1492 .map(|_| ())
1493 .map_err(|e| anyhow::anyhow!("Failed to update pool last synced block: {e}"))
1494 }
1495
1496 pub async fn get_dex_last_synced_block(
1502 &self,
1503 chain_id: u32,
1504 dex: &DexType,
1505 ) -> anyhow::Result<Option<u64>> {
1506 let result = sqlx::query_as::<_, (Option<i64>,)>(
1507 "
1508 SELECT
1509 last_full_sync_pools_block_number
1510 FROM dex
1511 WHERE chain_id = $1
1512 AND name = $2
1513 ",
1514 )
1515 .bind(chain_id as i32)
1516 .bind(dex.to_string())
1517 .fetch_optional(&self.pool)
1518 .await
1519 .map_err(|e| anyhow::anyhow!("Failed to get dex last synced block: {e}"))?;
1520
1521 Ok(result.and_then(|(block_number,)| block_number.map(|b| b as u64)))
1522 }
1523
1524 pub async fn get_pool_last_synced_block(
1530 &self,
1531 chain_id: u32,
1532 dex: &DexType,
1533 pool_identifier: &PoolIdentifier,
1534 ) -> anyhow::Result<Option<u64>> {
1535 let result = sqlx::query_as::<_, (Option<i64>,)>(
1536 "
1537 SELECT
1538 last_full_sync_block_number
1539 FROM pool
1540 WHERE chain_id = $1
1541 AND dex_name = $2
1542 AND pool_identifier = $3
1543 ",
1544 )
1545 .bind(chain_id as i32)
1546 .bind(dex.to_string())
1547 .bind(pool_identifier.as_ref())
1548 .fetch_optional(&self.pool)
1549 .await
1550 .map_err(|e| anyhow::anyhow!("Failed to get pool last synced block: {e}"))?;
1551
1552 Ok(result.and_then(|(block_number,)| block_number.map(|b| b as u64)))
1553 }
1554
1555 pub(crate) async fn get_pool_event_sync_state(
1556 &self,
1557 chain_id: u32,
1558 dex: &DexType,
1559 pool_identifier: &PoolIdentifier,
1560 ) -> anyhow::Result<PoolEventSyncState> {
1561 let pool_state = sqlx::query_as::<_, (i32, Option<i64>)>(
1562 "
1563 SELECT event_sync_version, last_full_sync_block_number
1564 FROM pool
1565 WHERE chain_id = $1
1566 AND dex_name = $2
1567 AND pool_identifier = $3
1568 ",
1569 )
1570 .bind(chain_id as i32)
1571 .bind(dex.to_string())
1572 .bind(pool_identifier.as_ref())
1573 .fetch_optional(&self.pool)
1574 .await
1575 .map_err(|e| anyhow::anyhow!("Failed to get pool event sync state: {e}"))?;
1576
1577 let Some((version, last_full_sync_block)) = pool_state else {
1578 return Ok(PoolEventSyncState::default());
1579 };
1580
1581 let family_blocks = sqlx::query_as::<_, (String, i64)>(
1582 "
1583 SELECT event_family, last_full_sync_block_number
1584 FROM pool_event_sync
1585 WHERE chain_id = $1
1586 AND dex_name = $2
1587 AND pool_identifier = $3
1588 ORDER BY event_family
1589 ",
1590 )
1591 .bind(chain_id as i32)
1592 .bind(dex.to_string())
1593 .bind(pool_identifier.as_ref())
1594 .fetch_all(&self.pool)
1595 .await
1596 .map_err(|e| anyhow::anyhow!("Failed to get pool event-family checkpoints: {e}"))?
1597 .into_iter()
1598 .map(|(family, block)| (family, block as u64))
1599 .collect();
1600
1601 Ok(PoolEventSyncState {
1602 version: version as u32,
1603 last_full_sync_block: last_full_sync_block.map(|block| block as u64),
1604 family_blocks,
1605 })
1606 }
1607
1608 pub(crate) async fn update_pool_event_sync(
1609 &self,
1610 chain_id: u32,
1611 dex: &DexType,
1612 pool_identifier: &PoolIdentifier,
1613 event_families: &[&str],
1614 block_number: u64,
1615 version: Option<u32>,
1616 ) -> anyhow::Result<()> {
1617 let mut transaction = self.pool.begin().await?;
1618
1619 for event_family in event_families {
1620 sqlx::query(
1621 "
1622 INSERT INTO pool_event_sync (
1623 chain_id, dex_name, pool_identifier, event_family,
1624 last_full_sync_block_number
1625 )
1626 VALUES ($1, $2, $3, $4, $5)
1627 ON CONFLICT (chain_id, dex_name, pool_identifier, event_family)
1628 DO UPDATE SET last_full_sync_block_number =
1629 GREATEST(
1630 pool_event_sync.last_full_sync_block_number,
1631 EXCLUDED.last_full_sync_block_number
1632 )
1633 ",
1634 )
1635 .bind(chain_id as i32)
1636 .bind(dex.to_string())
1637 .bind(pool_identifier.as_ref())
1638 .bind(event_family)
1639 .bind(block_number as i64)
1640 .execute(&mut *transaction)
1641 .await
1642 .map_err(|e| {
1643 anyhow::anyhow!("Failed to update {event_family} pool event-family checkpoint: {e}")
1644 })?;
1645 }
1646
1647 if let Some(version) = version {
1648 sqlx::query(
1649 "
1650 UPDATE pool
1651 SET
1652 event_sync_version = GREATEST(event_sync_version, $4),
1653 last_full_sync_block_number =
1654 GREATEST(COALESCE(last_full_sync_block_number, $5), $5)
1655 WHERE chain_id = $1
1656 AND dex_name = $2
1657 AND pool_identifier = $3
1658 ",
1659 )
1660 .bind(chain_id as i32)
1661 .bind(dex.to_string())
1662 .bind(pool_identifier.as_ref())
1663 .bind(version as i32)
1664 .bind(block_number as i64)
1665 .execute(&mut *transaction)
1666 .await
1667 .map_err(|e| anyhow::anyhow!("Failed to finalize pool event sync progress: {e}"))?;
1668 }
1669
1670 transaction.commit().await?;
1671 Ok(())
1672 }
1673
1674 pub async fn get_table_last_block(
1681 &self,
1682 chain_id: u32,
1683 table_name: &str,
1684 pool_identifier: &PoolIdentifier,
1685 ) -> anyhow::Result<Option<u64>> {
1686 let query = format!(
1687 "SELECT MAX(block) FROM {table_name} WHERE chain_id = $1 AND pool_identifier = $2"
1688 );
1689 let result = sqlx::query_as::<_, (Option<i64>,)>(AssertSqlSafe(query))
1690 .bind(chain_id as i32)
1691 .bind(pool_identifier.as_ref())
1692 .fetch_optional(&self.pool)
1693 .await
1694 .map_err(|e| anyhow::anyhow!("Failed to get table last block for {table_name}: {e}"))?;
1695
1696 Ok(result.and_then(|(block_number,)| block_number.map(|b| b as u64)))
1697 }
1698
1699 pub async fn add_pool_collects_batch(
1705 &self,
1706 chain_id: u32,
1707 collects: &[PoolFeeCollect],
1708 ) -> anyhow::Result<()> {
1709 if collects.is_empty() {
1710 return Ok(());
1711 }
1712
1713 let len = collects.len();
1715 let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1716 let mut dex_names: Vec<String> = Vec::with_capacity(len);
1717 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1718 let mut blocks: Vec<i64> = Vec::with_capacity(len);
1719 let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1720 let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1721 let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1722 let mut owners: Vec<String> = Vec::with_capacity(len);
1723 let mut amount0s: Vec<String> = Vec::with_capacity(len);
1724 let mut amount1s: Vec<String> = Vec::with_capacity(len);
1725 let mut tick_lowers: Vec<i32> = Vec::with_capacity(len);
1726 let mut tick_uppers: Vec<i32> = Vec::with_capacity(len);
1727
1728 for collect in collects {
1730 chain_ids.push(chain_id as i32);
1731 dex_names.push(collect.dex.name.to_string());
1732 pool_identifiers.push(collect.pool_identifier.to_string());
1733 blocks.push(collect.block as i64);
1734 transaction_hashes.push(collect.transaction_hash.clone());
1735 transaction_indices.push(collect.transaction_index as i32);
1736 log_indices.push(collect.log_index as i32);
1737 owners.push(collect.owner.to_string());
1738 amount0s.push(collect.amount0.to_string());
1739 amount1s.push(collect.amount1.to_string());
1740 tick_lowers.push(collect.tick_lower);
1741 tick_uppers.push(collect.tick_upper);
1742 }
1743
1744 sqlx::query(
1746 "
1747 INSERT INTO pool_collect_event (
1748 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1749 log_index, owner, amount0, amount1, tick_lower, tick_upper
1750 )
1751 SELECT
1752 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1753 log_index, owner, amount0::U256, amount1::U256, tick_lower, tick_upper
1754 FROM UNNEST(
1755 $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
1756 $7::INT[], $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::INT[], $12::INT[]
1757 ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1758 log_index, owner, amount0, amount1, tick_lower, tick_upper)
1759 ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
1760 ",
1761 )
1762 .bind(&chain_ids[..])
1763 .bind(&dex_names[..])
1764 .bind(&pool_identifiers[..])
1765 .bind(&blocks[..])
1766 .bind(&transaction_hashes[..])
1767 .bind(&transaction_indices[..])
1768 .bind(&log_indices[..])
1769 .bind(&owners[..])
1770 .bind(&amount0s[..])
1771 .bind(&amount1s[..])
1772 .bind(&tick_lowers[..])
1773 .bind(&tick_uppers[..])
1774 .execute(&self.pool)
1775 .await
1776 .map(|_| ())
1777 .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_fee_collect table: {e}"))
1778 }
1779
1780 pub async fn add_pool_flash_batch(
1786 &self,
1787 chain_id: u32,
1788 flash_events: &[PoolFlash],
1789 ) -> anyhow::Result<()> {
1790 if flash_events.is_empty() {
1791 return Ok(());
1792 }
1793
1794 let len = flash_events.len();
1796 let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1797 let mut dex_names: Vec<String> = Vec::with_capacity(len);
1798 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1799 let mut blocks: Vec<i64> = Vec::with_capacity(len);
1800 let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1801 let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1802 let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1803 let mut senders: Vec<String> = Vec::with_capacity(len);
1804 let mut recipients: Vec<String> = Vec::with_capacity(len);
1805 let mut amount0s: Vec<String> = Vec::with_capacity(len);
1806 let mut amount1s: Vec<String> = Vec::with_capacity(len);
1807 let mut paid0s: Vec<String> = Vec::with_capacity(len);
1808 let mut paid1s: Vec<String> = Vec::with_capacity(len);
1809
1810 for flash in flash_events {
1812 chain_ids.push(chain_id as i32);
1813 dex_names.push(flash.dex.name.to_string());
1814 pool_identifiers.push(flash.pool_identifier.to_string());
1815 blocks.push(flash.block as i64);
1816 transaction_hashes.push(flash.transaction_hash.clone());
1817 transaction_indices.push(flash.transaction_index as i32);
1818 log_indices.push(flash.log_index as i32);
1819 senders.push(flash.sender.to_string());
1820 recipients.push(flash.recipient.to_string());
1821 amount0s.push(flash.amount0.to_string());
1822 amount1s.push(flash.amount1.to_string());
1823 paid0s.push(flash.paid0.to_string());
1824 paid1s.push(flash.paid1.to_string());
1825 }
1826
1827 sqlx::query(
1829 "
1830 INSERT INTO pool_flash_event (
1831 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1832 log_index, sender, recipient, amount0, amount1, paid0, paid1
1833 )
1834 SELECT
1835 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1836 log_index, sender, recipient, amount0::U256, amount1::U256, paid0::U256, paid1::U256
1837 FROM UNNEST(
1838 $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
1839 $7::INT[], $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::TEXT[], $12::TEXT[], $13::TEXT[]
1840 ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1841 log_index, sender, recipient, amount0, amount1, paid0, paid1)
1842 ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
1843 ",
1844 )
1845 .bind(&chain_ids[..])
1846 .bind(&dex_names[..])
1847 .bind(&pool_identifiers[..])
1848 .bind(&blocks[..])
1849 .bind(&transaction_hashes[..])
1850 .bind(&transaction_indices[..])
1851 .bind(&log_indices[..])
1852 .bind(&senders[..])
1853 .bind(&recipients[..])
1854 .bind(&amount0s[..])
1855 .bind(&amount1s[..])
1856 .bind(&paid0s[..])
1857 .bind(&paid1s[..])
1858 .execute(&self.pool)
1859 .await
1860 .map(|_| ())
1861 .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_flash_event table: {e}"))
1862 }
1863
1864 pub async fn add_pool_fee_protocol_updates_batch(
1870 &self,
1871 chain_id: u32,
1872 updates: &[PoolFeeProtocolUpdate],
1873 ) -> anyhow::Result<()> {
1874 if updates.is_empty() {
1875 return Ok(());
1876 }
1877
1878 let len = updates.len();
1880 let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1881 let mut dex_names: Vec<String> = Vec::with_capacity(len);
1882 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1883 let mut blocks: Vec<i64> = Vec::with_capacity(len);
1884 let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1885 let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1886 let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1887 let mut fee_protocol0s: Vec<i32> = Vec::with_capacity(len);
1888 let mut fee_protocol1s: Vec<i32> = Vec::with_capacity(len);
1889
1890 for update in updates {
1892 chain_ids.push(chain_id as i32);
1893 dex_names.push(update.dex.name.to_string());
1894 pool_identifiers.push(update.pool_identifier.to_string());
1895 blocks.push(update.block as i64);
1896 transaction_hashes.push(update.transaction_hash.clone());
1897 transaction_indices.push(update.transaction_index as i32);
1898 log_indices.push(update.log_index as i32);
1899 fee_protocol0s.push(i32::try_from(update.fee_protocol0_new).map_err(|e| {
1900 anyhow::anyhow!(
1901 "Invalid fee_protocol0_new '{}': {e}",
1902 update.fee_protocol0_new
1903 )
1904 })?);
1905 fee_protocol1s.push(i32::try_from(update.fee_protocol1_new).map_err(|e| {
1906 anyhow::anyhow!(
1907 "Invalid fee_protocol1_new '{}': {e}",
1908 update.fee_protocol1_new
1909 )
1910 })?);
1911 }
1912
1913 sqlx::query(
1915 "
1916 INSERT INTO pool_fee_protocol_update_event (
1917 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1918 log_index, fee_protocol0_new, fee_protocol1_new
1919 )
1920 SELECT
1921 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1922 log_index, fee_protocol0_new, fee_protocol1_new
1923 FROM UNNEST(
1924 $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
1925 $7::INT[], $8::INT[], $9::INT[]
1926 ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1927 log_index, fee_protocol0_new, fee_protocol1_new)
1928 ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
1929 ",
1930 )
1931 .bind(&chain_ids[..])
1932 .bind(&dex_names[..])
1933 .bind(&pool_identifiers[..])
1934 .bind(&blocks[..])
1935 .bind(&transaction_hashes[..])
1936 .bind(&transaction_indices[..])
1937 .bind(&log_indices[..])
1938 .bind(&fee_protocol0s[..])
1939 .bind(&fee_protocol1s[..])
1940 .execute(&self.pool)
1941 .await
1942 .map(|_| ())
1943 .map_err(|e| {
1944 anyhow::anyhow!("Failed to batch insert into pool_fee_protocol_update_event table: {e}")
1945 })
1946 }
1947
1948 pub async fn add_pool_fee_protocol_collect_batch(
1954 &self,
1955 chain_id: u32,
1956 collects: &[PoolFeeProtocolCollect],
1957 ) -> anyhow::Result<()> {
1958 if collects.is_empty() {
1959 return Ok(());
1960 }
1961
1962 let len = collects.len();
1964 let mut chain_ids: Vec<i32> = Vec::with_capacity(len);
1965 let mut dex_names: Vec<String> = Vec::with_capacity(len);
1966 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
1967 let mut blocks: Vec<i64> = Vec::with_capacity(len);
1968 let mut transaction_hashes: Vec<String> = Vec::with_capacity(len);
1969 let mut transaction_indices: Vec<i32> = Vec::with_capacity(len);
1970 let mut log_indices: Vec<i32> = Vec::with_capacity(len);
1971 let mut senders: Vec<String> = Vec::with_capacity(len);
1972 let mut recipients: Vec<String> = Vec::with_capacity(len);
1973 let mut amount0s: Vec<String> = Vec::with_capacity(len);
1974 let mut amount1s: Vec<String> = Vec::with_capacity(len);
1975
1976 for collect in collects {
1978 chain_ids.push(chain_id as i32);
1979 dex_names.push(collect.dex.name.to_string());
1980 pool_identifiers.push(collect.pool_identifier.to_string());
1981 blocks.push(collect.block as i64);
1982 transaction_hashes.push(collect.transaction_hash.clone());
1983 transaction_indices.push(collect.transaction_index as i32);
1984 log_indices.push(collect.log_index as i32);
1985 senders.push(collect.sender.to_string());
1986 recipients.push(collect.recipient.to_string());
1987 amount0s.push(collect.amount0.to_string());
1988 amount1s.push(collect.amount1.to_string());
1989 }
1990
1991 sqlx::query(
1993 "
1994 INSERT INTO pool_fee_protocol_collect_event (
1995 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
1996 log_index, sender, recipient, amount0, amount1
1997 )
1998 SELECT
1999 chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
2000 log_index, sender, recipient, amount0::U256, amount1::U256
2001 FROM UNNEST(
2002 $1::INT[], $2::TEXT[], $3::TEXT[], $4::INT[], $5::TEXT[], $6::INT[],
2003 $7::INT[], $8::TEXT[], $9::TEXT[], $10::TEXT[], $11::TEXT[]
2004 ) AS t(chain_id, dex_name, pool_identifier, block, transaction_hash, transaction_index,
2005 log_index, sender, recipient, amount0, amount1)
2006 ON CONFLICT (chain_id, transaction_hash, log_index) DO NOTHING
2007 ",
2008 )
2009 .bind(&chain_ids[..])
2010 .bind(&dex_names[..])
2011 .bind(&pool_identifiers[..])
2012 .bind(&blocks[..])
2013 .bind(&transaction_hashes[..])
2014 .bind(&transaction_indices[..])
2015 .bind(&log_indices[..])
2016 .bind(&senders[..])
2017 .bind(&recipients[..])
2018 .bind(&amount0s[..])
2019 .bind(&amount1s[..])
2020 .execute(&self.pool)
2021 .await
2022 .map(|_| ())
2023 .map_err(|e| {
2024 anyhow::anyhow!(
2025 "Failed to batch insert into pool_fee_protocol_collect_event table: {e}"
2026 )
2027 })
2028 }
2029
2030 pub async fn add_pool_snapshot(
2036 &self,
2037 chain_id: u32,
2038 dex_name: &DexType,
2039 pool_identifier: &PoolIdentifier,
2040 snapshot: &PoolSnapshot,
2041 ) -> anyhow::Result<()> {
2042 sqlx::query(
2043 "
2044 INSERT INTO pool_snapshot (
2045 chain_id, dex_name, pool_identifier, block, transaction_index, log_index, transaction_hash,
2046 current_tick, price_sqrt_ratio_x96, liquidity,
2047 protocol_fees_token0, protocol_fees_token1, fee_protocol,
2048 fee_protocol0_basis_points, fee_protocol1_basis_points,
2049 fee_growth_global_0, fee_growth_global_1,
2050 total_amount0_deposited, total_amount1_deposited,
2051 total_amount0_collected, total_amount1_collected,
2052 total_swaps, total_mints, total_burns, total_fee_collects, total_flashes,
2053 liquidity_utilization_rate
2054 ) VALUES (
2055 $1, $2, $3, $4, $5, $6, $7,
2056 $8, $9::U160, $10::U128, $11::U256, $12::U256, $13, $14, $15,
2057 $16::U256, $17::U256, $18::U256, $19::U256, $20::U256, $21::U256,
2058 $22, $23, $24, $25, $26, $27
2059 )
2060 ON CONFLICT (chain_id, pool_identifier, block, transaction_index, log_index)
2061 DO UPDATE SET
2062 dex_name = EXCLUDED.dex_name,
2063 transaction_hash = EXCLUDED.transaction_hash,
2064 current_tick = EXCLUDED.current_tick,
2065 price_sqrt_ratio_x96 = EXCLUDED.price_sqrt_ratio_x96,
2066 liquidity = EXCLUDED.liquidity,
2067 protocol_fees_token0 = EXCLUDED.protocol_fees_token0,
2068 protocol_fees_token1 = EXCLUDED.protocol_fees_token1,
2069 fee_protocol = EXCLUDED.fee_protocol,
2070 fee_protocol0_basis_points = EXCLUDED.fee_protocol0_basis_points,
2071 fee_protocol1_basis_points = EXCLUDED.fee_protocol1_basis_points,
2072 fee_growth_global_0 = EXCLUDED.fee_growth_global_0,
2073 fee_growth_global_1 = EXCLUDED.fee_growth_global_1,
2074 total_amount0_deposited = EXCLUDED.total_amount0_deposited,
2075 total_amount1_deposited = EXCLUDED.total_amount1_deposited,
2076 total_amount0_collected = EXCLUDED.total_amount0_collected,
2077 total_amount1_collected = EXCLUDED.total_amount1_collected,
2078 total_swaps = EXCLUDED.total_swaps,
2079 total_mints = EXCLUDED.total_mints,
2080 total_burns = EXCLUDED.total_burns,
2081 total_fee_collects = EXCLUDED.total_fee_collects,
2082 total_flashes = EXCLUDED.total_flashes,
2083 liquidity_utilization_rate = EXCLUDED.liquidity_utilization_rate
2084 ",
2085 )
2086 .bind(chain_id as i32)
2087 .bind(dex_name.to_string())
2088 .bind(pool_identifier.as_ref())
2089 .bind(snapshot.block_position.number as i64)
2090 .bind(snapshot.block_position.transaction_index as i32)
2091 .bind(snapshot.block_position.log_index as i32)
2092 .bind(snapshot.block_position.transaction_hash.clone())
2093 .bind(snapshot.state.current_tick)
2094 .bind(snapshot.state.price_sqrt_ratio_x96.to_string())
2095 .bind(snapshot.state.liquidity.to_string())
2096 .bind(snapshot.state.protocol_fees_token0.to_string())
2097 .bind(snapshot.state.protocol_fees_token1.to_string())
2098 .bind(snapshot.state.fee_protocol as i16)
2099 .bind(snapshot.state.fee_protocol0_basis_points.map(|v| v as i32))
2100 .bind(snapshot.state.fee_protocol1_basis_points.map(|v| v as i32))
2101 .bind(snapshot.state.fee_growth_global_0.to_string())
2102 .bind(snapshot.state.fee_growth_global_1.to_string())
2103 .bind(snapshot.analytics.total_amount0_deposited.to_string())
2104 .bind(snapshot.analytics.total_amount1_deposited.to_string())
2105 .bind(snapshot.analytics.total_amount0_collected.to_string())
2106 .bind(snapshot.analytics.total_amount1_collected.to_string())
2107 .bind(snapshot.analytics.total_swaps as i32)
2108 .bind(snapshot.analytics.total_mints as i32)
2109 .bind(snapshot.analytics.total_burns as i32)
2110 .bind(snapshot.analytics.total_fee_collects as i32)
2111 .bind(snapshot.analytics.total_flashes as i32)
2112 .bind(snapshot.analytics.liquidity_utilization_rate)
2113 .execute(&self.pool)
2114 .await
2115 .map(|_| ())
2116 .map_err(|e| anyhow::anyhow!("Failed to insert into pool_snapshot table: {e}"))
2117 }
2118
2119 pub async fn add_pool_positions_batch(
2125 &self,
2126 chain_id: u32,
2127 snapshot_block: u64,
2128 snapshot_transaction_index: u32,
2129 snapshot_log_index: u32,
2130 positions: &[(PoolIdentifier, PoolPosition)],
2131 ) -> anyhow::Result<()> {
2132 if positions.is_empty() {
2133 return Ok(());
2134 }
2135
2136 let len = positions.len();
2138 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
2139 let mut owners: Vec<String> = Vec::with_capacity(len);
2140 let mut tick_lowers: Vec<i32> = Vec::with_capacity(len);
2141 let mut tick_uppers: Vec<i32> = Vec::with_capacity(len);
2142 let mut liquidities: Vec<String> = Vec::with_capacity(len);
2143 let mut fee_growth_inside_0_lasts: Vec<String> = Vec::with_capacity(len);
2144 let mut fee_growth_inside_1_lasts: Vec<String> = Vec::with_capacity(len);
2145 let mut tokens_owed_0s: Vec<String> = Vec::with_capacity(len);
2146 let mut tokens_owed_1s: Vec<String> = Vec::with_capacity(len);
2147 let mut total_amount0_depositeds: Vec<Option<String>> = Vec::with_capacity(len);
2148 let mut total_amount1_depositeds: Vec<Option<String>> = Vec::with_capacity(len);
2149 let mut total_amount0_collecteds: Vec<Option<String>> = Vec::with_capacity(len);
2150 let mut total_amount1_collecteds: Vec<Option<String>> = Vec::with_capacity(len);
2151
2152 for (pool_address, position) in positions {
2154 pool_identifiers.push(pool_address.to_string());
2155 owners.push(position.owner.to_string());
2156 tick_lowers.push(position.tick_lower);
2157 tick_uppers.push(position.tick_upper);
2158 liquidities.push(position.liquidity.to_string());
2159 fee_growth_inside_0_lasts.push(position.fee_growth_inside_0_last.to_string());
2160 fee_growth_inside_1_lasts.push(position.fee_growth_inside_1_last.to_string());
2161 tokens_owed_0s.push(position.tokens_owed_0.to_string());
2162 tokens_owed_1s.push(position.tokens_owed_1.to_string());
2163 total_amount0_depositeds.push(Some(position.total_amount0_deposited.to_string()));
2164 total_amount1_depositeds.push(Some(position.total_amount1_deposited.to_string()));
2165 total_amount0_collecteds.push(Some(position.total_amount0_collected.to_string()));
2166 total_amount1_collecteds.push(Some(position.total_amount1_collected.to_string()));
2167 }
2168
2169 sqlx::query(
2171 "
2172 INSERT INTO pool_position (
2173 chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index,
2174 owner, tick_lower, tick_upper,
2175 liquidity, fee_growth_inside_0_last, fee_growth_inside_1_last,
2176 tokens_owed_0, tokens_owed_1,
2177 total_amount0_deposited, total_amount1_deposited,
2178 total_amount0_collected, total_amount1_collected
2179 )
2180 SELECT
2181 $1, pool_identifier, $2, $3, $4,
2182 owner, tick_lower, tick_upper,
2183 liquidity::U128, fee_growth_inside_0_last::U256, fee_growth_inside_1_last::U256,
2184 tokens_owed_0::U128, tokens_owed_1::U128,
2185 total_amount0_deposited::U256, total_amount1_deposited::U256,
2186 total_amount0_collected::U128, total_amount1_collected::U128
2187 FROM UNNEST(
2188 $5::TEXT[], $6::TEXT[], $7::INT[], $8::INT[], $9::TEXT[], $10::TEXT[],
2189 $11::TEXT[], $12::TEXT[], $13::TEXT[], $14::TEXT[], $15::TEXT[],
2190 $16::TEXT[], $17::TEXT[]
2191 ) AS t(pool_identifier, owner, tick_lower, tick_upper,
2192 liquidity, fee_growth_inside_0_last, fee_growth_inside_1_last,
2193 tokens_owed_0, tokens_owed_1,
2194 total_amount0_deposited, total_amount1_deposited,
2195 total_amount0_collected, total_amount1_collected)
2196 ON CONFLICT (chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index, owner, tick_lower, tick_upper)
2197 DO UPDATE SET
2198 liquidity = EXCLUDED.liquidity,
2199 fee_growth_inside_0_last = EXCLUDED.fee_growth_inside_0_last,
2200 fee_growth_inside_1_last = EXCLUDED.fee_growth_inside_1_last,
2201 tokens_owed_0 = EXCLUDED.tokens_owed_0,
2202 tokens_owed_1 = EXCLUDED.tokens_owed_1,
2203 total_amount0_deposited = EXCLUDED.total_amount0_deposited,
2204 total_amount1_deposited = EXCLUDED.total_amount1_deposited,
2205 total_amount0_collected = EXCLUDED.total_amount0_collected,
2206 total_amount1_collected = EXCLUDED.total_amount1_collected
2207 ",
2208 )
2209 .bind(chain_id as i32)
2210 .bind(snapshot_block as i64)
2211 .bind(snapshot_transaction_index as i32)
2212 .bind(snapshot_log_index as i32)
2213 .bind(&pool_identifiers[..])
2214 .bind(&owners[..])
2215 .bind(&tick_lowers[..])
2216 .bind(&tick_uppers[..])
2217 .bind(&liquidities[..])
2218 .bind(&fee_growth_inside_0_lasts[..])
2219 .bind(&fee_growth_inside_1_lasts[..])
2220 .bind(&tokens_owed_0s[..])
2221 .bind(&tokens_owed_1s[..])
2222 .bind(&total_amount0_depositeds as &[Option<String>])
2223 .bind(&total_amount1_depositeds as &[Option<String>])
2224 .bind(&total_amount0_collecteds as &[Option<String>])
2225 .bind(&total_amount1_collecteds as &[Option<String>])
2226 .execute(&self.pool)
2227 .await
2228 .map(|_| ())
2229 .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_position table: {e}"))
2230 }
2231
2232 pub async fn add_pool_ticks_batch(
2238 &self,
2239 chain_id: u32,
2240 snapshot_block: u64,
2241 snapshot_transaction_index: u32,
2242 snapshot_log_index: u32,
2243 ticks: &[(PoolIdentifier, &PoolTick)],
2244 ) -> anyhow::Result<()> {
2245 if ticks.is_empty() {
2246 return Ok(());
2247 }
2248
2249 let len = ticks.len();
2251 let mut pool_identifiers: Vec<String> = Vec::with_capacity(len);
2252 let mut tick_values: Vec<i32> = Vec::with_capacity(len);
2253 let mut liquidity_grosses: Vec<String> = Vec::with_capacity(len);
2254 let mut liquidity_nets: Vec<String> = Vec::with_capacity(len);
2255 let mut fee_growth_outside_0s: Vec<String> = Vec::with_capacity(len);
2256 let mut fee_growth_outside_1s: Vec<String> = Vec::with_capacity(len);
2257 let mut initializeds: Vec<bool> = Vec::with_capacity(len);
2258 let mut last_updated_blocks: Vec<i64> = Vec::with_capacity(len);
2259
2260 for (pool_address, tick) in ticks {
2262 pool_identifiers.push(pool_address.to_string());
2263 tick_values.push(tick.value);
2264 liquidity_grosses.push(tick.liquidity_gross.to_string());
2265 liquidity_nets.push(tick.liquidity_net.to_string());
2266 fee_growth_outside_0s.push(tick.fee_growth_outside_0.to_string());
2267 fee_growth_outside_1s.push(tick.fee_growth_outside_1.to_string());
2268 initializeds.push(tick.initialized);
2269 last_updated_blocks.push(tick.last_updated_block as i64);
2270 }
2271
2272 sqlx::query(
2274 "
2275 INSERT INTO pool_tick (
2276 chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index,
2277 tick_value, liquidity_gross, liquidity_net,
2278 fee_growth_outside_0, fee_growth_outside_1, initialized, last_updated_block
2279 )
2280 SELECT
2281 $1, pool_identifier, $2, $3, $4,
2282 tick_value, liquidity_gross::U128, liquidity_net::I128,
2283 fee_growth_outside_0::U256, fee_growth_outside_1::U256, initialized, last_updated_block
2284 FROM UNNEST(
2285 $5::TEXT[], $6::INT[], $7::TEXT[], $8::TEXT[], $9::TEXT[],
2286 $10::TEXT[], $11::BOOLEAN[], $12::BIGINT[]
2287 ) AS t(pool_identifier, tick_value, liquidity_gross, liquidity_net,
2288 fee_growth_outside_0, fee_growth_outside_1, initialized, last_updated_block)
2289 ON CONFLICT (chain_id, pool_identifier, snapshot_block, snapshot_transaction_index, snapshot_log_index, tick_value)
2290 DO UPDATE SET
2291 liquidity_gross = EXCLUDED.liquidity_gross,
2292 liquidity_net = EXCLUDED.liquidity_net,
2293 fee_growth_outside_0 = EXCLUDED.fee_growth_outside_0,
2294 fee_growth_outside_1 = EXCLUDED.fee_growth_outside_1,
2295 initialized = EXCLUDED.initialized,
2296 last_updated_block = EXCLUDED.last_updated_block
2297 ",
2298 )
2299 .bind(chain_id as i32)
2300 .bind(snapshot_block as i64)
2301 .bind(snapshot_transaction_index as i32)
2302 .bind(snapshot_log_index as i32)
2303 .bind(&pool_identifiers[..])
2304 .bind(&tick_values[..])
2305 .bind(&liquidity_grosses[..])
2306 .bind(&liquidity_nets[..])
2307 .bind(&fee_growth_outside_0s[..])
2308 .bind(&fee_growth_outside_1s[..])
2309 .bind(&initializeds[..])
2310 .bind(&last_updated_blocks[..])
2311 .execute(&self.pool)
2312 .await
2313 .map(|_| ())
2314 .map_err(|e| anyhow::anyhow!("Failed to batch insert into pool_tick table: {e}"))
2315 }
2316
2317 pub async fn update_pool_initial_price_tick(
2323 &self,
2324 chain_id: u32,
2325 initialize_event: &InitializeEvent,
2326 ) -> anyhow::Result<()> {
2327 sqlx::query(
2328 "
2329 UPDATE pool
2330 SET
2331 initial_tick = $4,
2332 initial_sqrt_price_x96 = $5
2333 WHERE chain_id = $1
2334 AND dex_name = $2
2335 AND pool_identifier = $3
2336 ",
2337 )
2338 .bind(chain_id as i32)
2339 .bind(initialize_event.dex.name.to_string())
2340 .bind(initialize_event.pool_identifier.as_ref())
2341 .bind(initialize_event.tick)
2342 .bind(initialize_event.sqrt_price_x96.to_string())
2343 .execute(&self.pool)
2344 .await
2345 .map(|_| ())
2346 .map_err(|e| anyhow::anyhow!("Failed to update pool initial price and tick: {e}"))
2347 }
2348
2349 pub async fn load_latest_valid_pool_snapshot(
2358 &self,
2359 chain_id: u32,
2360 pool_identifier: &PoolIdentifier,
2361 ) -> anyhow::Result<Option<PoolSnapshot>> {
2362 self.load_latest_pool_snapshot(chain_id, pool_identifier, None, true)
2363 .await
2364 }
2365
2366 pub async fn load_latest_pool_snapshot(
2377 &self,
2378 chain_id: u32,
2379 pool_identifier: &PoolIdentifier,
2380 max_block: Option<u64>,
2381 require_valid: bool,
2382 ) -> anyhow::Result<Option<PoolSnapshot>> {
2383 let allow_invalid = !require_valid;
2384 let result = sqlx::query(
2385 "
2386 SELECT
2387 block, transaction_index, log_index, transaction_hash,
2388 COALESCE(
2389 (SELECT hash FROM pool_event_block WHERE pool_event_block.chain_id = pool_snapshot.chain_id AND pool_event_block.number = pool_snapshot.block),
2390 (SELECT hash FROM block WHERE block.chain_id = pool_snapshot.chain_id AND block.number = pool_snapshot.block)
2391 ) as block_hash,
2392 current_tick, price_sqrt_ratio_x96::TEXT, liquidity::TEXT,
2393 protocol_fees_token0::TEXT, protocol_fees_token1::TEXT, fee_protocol,
2394 fee_protocol0_basis_points, fee_protocol1_basis_points,
2395 fee_growth_global_0::TEXT, fee_growth_global_1::TEXT,
2396 total_amount0_deposited::TEXT, total_amount1_deposited::TEXT,
2397 total_amount0_collected::TEXT, total_amount1_collected::TEXT,
2398 total_swaps, total_mints, total_burns, total_fee_collects, total_flashes,
2399 liquidity_utilization_rate,
2400 (SELECT dex_name FROM pool WHERE chain_id = $1 AND address = $2) as dex_name,
2401 COALESCE(
2402 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_snapshot.chain_id AND block.number = pool_snapshot.block),
2403 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_snapshot.chain_id AND pool_event_block.number = pool_snapshot.block)
2404 ) as block_timestamp
2405 FROM pool_snapshot
2406 WHERE chain_id = $1 AND pool_identifier = $2
2407 AND ($3::BIGINT IS NULL OR block <= $3)
2408 AND ($4 OR validation_state <> 'invalid')
2409 ORDER BY block DESC, transaction_index DESC, log_index DESC
2410 LIMIT 1
2411 ",
2412 )
2413 .bind(chain_id as i32)
2414 .bind(pool_identifier.as_ref())
2415 .bind(max_block.map(|b| b as i64))
2416 .bind(allow_invalid)
2417 .fetch_optional(&self.pool)
2418 .await
2419 .map_err(|e| anyhow::anyhow!("Failed to load latest valid pool snapshot: {e}"))?;
2420
2421 if let Some(row) = result {
2422 let block: i64 = row.get("block");
2424 let transaction_index: i32 = row.get("transaction_index");
2425 let log_index: i32 = row.get("log_index");
2426 let transaction_hash: String = row.get("transaction_hash");
2427 let observed_block_hash = row.try_get::<Option<String>, _>("block_hash")?;
2428 let block =
2429 u64::try_from(block).with_context(|| "Pool snapshot block number is negative")?;
2430 let transaction_index = u32::try_from(transaction_index)
2431 .with_context(|| "Pool snapshot transaction index is negative")?;
2432 let log_index =
2433 u32::try_from(log_index).with_context(|| "Pool snapshot log index is negative")?;
2434 let block_hash = if transaction_index == BLOCK_SCOPED_SNAPSHOT_INDEX
2435 && log_index == BLOCK_SCOPED_SNAPSHOT_INDEX
2436 {
2437 Some(transaction_hash.clone())
2438 } else {
2439 observed_block_hash
2440 };
2441 let block_timestamp = row
2442 .try_get::<Option<String>, _>("block_timestamp")?
2443 .ok_or_else(|| {
2444 anyhow::anyhow!(
2445 "Missing block timestamp for pool snapshot {} at block {}",
2446 pool_identifier,
2447 block
2448 )
2449 })?;
2450 let timestamp = parse_cached_block_timestamp(&block_timestamp)
2451 .map_err(|e| anyhow::anyhow!("Invalid block timestamp '{block_timestamp}': {e}"))?;
2452
2453 let block_position =
2454 BlockPosition::new(block, transaction_hash, transaction_index, log_index)
2455 .with_block_hash(block_hash);
2456
2457 let fee_protocol_value = row.get::<i16, _>("fee_protocol");
2458 let fee_protocol = u8::try_from(fee_protocol_value).with_context(|| {
2459 format!("Invalid pool snapshot fee protocol {fee_protocol_value}")
2460 })?;
2461 let fee_protocol0_basis_points = row
2462 .get::<Option<i32>, _>("fee_protocol0_basis_points")
2463 .map(|value| {
2464 u32::try_from(value).with_context(|| {
2465 format!("Invalid token0 fee protocol basis points {value}")
2466 })
2467 })
2468 .transpose()?;
2469 let fee_protocol1_basis_points = row
2470 .get::<Option<i32>, _>("fee_protocol1_basis_points")
2471 .map(|value| {
2472 u32::try_from(value).with_context(|| {
2473 format!("Invalid token1 fee protocol basis points {value}")
2474 })
2475 })
2476 .transpose()?;
2477
2478 let state = PoolState {
2479 current_tick: row.get("current_tick"),
2480 price_sqrt_ratio_x96: row.get::<String, _>("price_sqrt_ratio_x96").parse()?,
2481 liquidity: row.get::<String, _>("liquidity").parse()?,
2482 protocol_fees_token0: row.get::<String, _>("protocol_fees_token0").parse()?,
2483 protocol_fees_token1: row.get::<String, _>("protocol_fees_token1").parse()?,
2484 fee_protocol,
2485 fee_protocol0_basis_points,
2486 fee_protocol1_basis_points,
2487 fee_growth_global_0: row.get::<String, _>("fee_growth_global_0").parse()?,
2488 fee_growth_global_1: row.get::<String, _>("fee_growth_global_1").parse()?,
2489 };
2490
2491 let analytics = PoolAnalytics {
2492 total_amount0_deposited: row.get::<String, _>("total_amount0_deposited").parse()?,
2493 total_amount1_deposited: row.get::<String, _>("total_amount1_deposited").parse()?,
2494 total_amount0_collected: row.get::<String, _>("total_amount0_collected").parse()?,
2495 total_amount1_collected: row.get::<String, _>("total_amount1_collected").parse()?,
2496 total_swaps: u64::try_from(row.get::<i32, _>("total_swaps"))
2497 .with_context(|| "Pool snapshot total swaps is negative")?,
2498 total_mints: u64::try_from(row.get::<i32, _>("total_mints"))
2499 .with_context(|| "Pool snapshot total mints is negative")?,
2500 total_burns: u64::try_from(row.get::<i32, _>("total_burns"))
2501 .with_context(|| "Pool snapshot total burns is negative")?,
2502 total_fee_collects: u64::try_from(row.get::<i32, _>("total_fee_collects"))
2503 .with_context(|| "Pool snapshot total fee collects is negative")?,
2504 total_flashes: u64::try_from(row.get::<i32, _>("total_flashes"))
2505 .with_context(|| "Pool snapshot total flashes is negative")?,
2506 liquidity_utilization_rate: row.get::<f64, _>("liquidity_utilization_rate"),
2507 };
2508
2509 let positions = self
2511 .load_pool_positions_for_snapshot(
2512 chain_id,
2513 pool_identifier,
2514 block,
2515 transaction_index,
2516 log_index,
2517 )
2518 .await?;
2519
2520 let ticks = self
2521 .load_pool_ticks_for_snapshot(
2522 chain_id,
2523 pool_identifier,
2524 block,
2525 transaction_index,
2526 log_index,
2527 )
2528 .await?;
2529
2530 let dex_name = row
2531 .try_get::<Option<String>, _>("dex_name")?
2532 .ok_or_else(|| {
2533 anyhow::anyhow!("Missing dex_name for pool snapshot {}", pool_identifier)
2534 })?;
2535 let chain = Chain::from_chain_id(chain_id)
2536 .ok_or_else(|| anyhow::anyhow!("Unknown chain_id: {chain_id}"))?;
2537
2538 let dex_type = DexType::from_dex_name(&dex_name)
2539 .ok_or_else(|| anyhow::anyhow!("Unknown dex_name: {dex_name}"))?;
2540
2541 let dex_extended = crate::exchanges::get_dex_extended(chain.name, &dex_type)
2542 .ok_or_else(|| {
2543 anyhow::anyhow!("No DEX extended found for {} on {}", dex_name, chain.name)
2544 })?;
2545
2546 let instrument_id =
2547 Pool::create_instrument_id(chain.name, &dex_extended.dex, pool_identifier.as_ref());
2548
2549 Ok(Some(PoolSnapshot::new(
2550 instrument_id,
2551 state,
2552 positions,
2553 ticks,
2554 analytics,
2555 block_position,
2556 timestamp, timestamp, )))
2559 } else {
2560 Ok(None)
2561 }
2562 }
2563
2564 pub async fn set_pool_snapshot_validation_state(
2573 &self,
2574 chain_id: u32,
2575 pool_identifier: &PoolIdentifier,
2576 block: u64,
2577 transaction_index: u32,
2578 log_index: u32,
2579 state: &str,
2580 ) -> anyhow::Result<()> {
2581 sqlx::query(
2582 "
2583 UPDATE pool_snapshot
2584 SET validation_state = $6
2585 WHERE chain_id = $1
2586 AND pool_identifier = $2
2587 AND block = $3
2588 AND transaction_index = $4
2589 AND log_index = $5
2590 ",
2591 )
2592 .bind(chain_id as i32)
2593 .bind(pool_identifier.as_ref())
2594 .bind(block as i64)
2595 .bind(transaction_index as i32)
2596 .bind(log_index as i32)
2597 .bind(state)
2598 .execute(&self.pool)
2599 .await
2600 .map(|_| ())
2601 .map_err(|e| anyhow::anyhow!("Failed to set pool snapshot validation state: {e}"))
2602 }
2603
2604 pub async fn get_pool_snapshot_validation_state(
2613 &self,
2614 chain_id: u32,
2615 pool_identifier: &PoolIdentifier,
2616 block: u64,
2617 transaction_index: u32,
2618 log_index: u32,
2619 ) -> anyhow::Result<Option<String>> {
2620 let row = sqlx::query(
2621 "
2622 SELECT validation_state
2623 FROM pool_snapshot
2624 WHERE chain_id = $1
2625 AND pool_identifier = $2
2626 AND block = $3
2627 AND transaction_index = $4
2628 AND log_index = $5
2629 ",
2630 )
2631 .bind(chain_id as i32)
2632 .bind(pool_identifier.as_ref())
2633 .bind(block as i64)
2634 .bind(transaction_index as i32)
2635 .bind(log_index as i32)
2636 .fetch_optional(&self.pool)
2637 .await
2638 .map_err(|e| anyhow::anyhow!("Failed to get pool snapshot validation state: {e}"))?;
2639
2640 Ok(row.map(|row| row.get::<String, _>("validation_state")))
2641 }
2642
2643 pub async fn load_pool_positions_for_snapshot(
2649 &self,
2650 chain_id: u32,
2651 pool_identifier: &PoolIdentifier,
2652 snapshot_block: u64,
2653 snapshot_transaction_index: u32,
2654 snapshot_log_index: u32,
2655 ) -> anyhow::Result<Vec<PoolPosition>> {
2656 let rows = sqlx::query(
2657 "
2658 SELECT
2659 owner, tick_lower, tick_upper,
2660 liquidity::TEXT, fee_growth_inside_0_last::TEXT, fee_growth_inside_1_last::TEXT,
2661 tokens_owed_0::TEXT, tokens_owed_1::TEXT,
2662 total_amount0_deposited::TEXT, total_amount1_deposited::TEXT,
2663 total_amount0_collected::TEXT, total_amount1_collected::TEXT
2664 FROM pool_position
2665 WHERE chain_id = $1
2666 AND pool_identifier = $2
2667 AND snapshot_block = $3
2668 AND snapshot_transaction_index = $4
2669 AND snapshot_log_index = $5
2670 ",
2671 )
2672 .bind(chain_id as i32)
2673 .bind(pool_identifier.as_ref())
2674 .bind(snapshot_block as i64)
2675 .bind(snapshot_transaction_index as i32)
2676 .bind(snapshot_log_index as i32)
2677 .fetch_all(&self.pool)
2678 .await
2679 .map_err(|e| anyhow::anyhow!("Failed to load pool positions: {e}"))?;
2680
2681 rows.iter()
2682 .map(|row| {
2683 let owner: String = row.get("owner");
2684 let position = PoolPosition {
2685 owner: validate_address(&owner)?,
2686 tick_lower: row.get("tick_lower"),
2687 tick_upper: row.get("tick_upper"),
2688 liquidity: row.get::<String, _>("liquidity").parse()?,
2689 fee_growth_inside_0_last: row
2690 .get::<String, _>("fee_growth_inside_0_last")
2691 .parse()?,
2692 fee_growth_inside_1_last: row
2693 .get::<String, _>("fee_growth_inside_1_last")
2694 .parse()?,
2695 tokens_owed_0: row.get::<String, _>("tokens_owed_0").parse()?,
2696 tokens_owed_1: row.get::<String, _>("tokens_owed_1").parse()?,
2697 total_amount0_deposited: row
2698 .get::<String, _>("total_amount0_deposited")
2699 .parse()?,
2700 total_amount1_deposited: row
2701 .get::<String, _>("total_amount1_deposited")
2702 .parse()?,
2703 total_amount0_collected: row
2704 .get::<String, _>("total_amount0_collected")
2705 .parse()?,
2706 total_amount1_collected: row
2707 .get::<String, _>("total_amount1_collected")
2708 .parse()?,
2709 };
2710 Ok(position)
2711 })
2712 .collect()
2713 }
2714
2715 pub async fn load_pool_ticks_for_snapshot(
2721 &self,
2722 chain_id: u32,
2723 pool_identifier: &PoolIdentifier,
2724 snapshot_block: u64,
2725 snapshot_transaction_index: u32,
2726 snapshot_log_index: u32,
2727 ) -> anyhow::Result<Vec<PoolTick>> {
2728 let rows = sqlx::query(
2729 "
2730 SELECT
2731 tick_value, liquidity_gross::TEXT, liquidity_net::TEXT,
2732 fee_growth_outside_0::TEXT, fee_growth_outside_1::TEXT, initialized,
2733 last_updated_block
2734 FROM pool_tick
2735 WHERE chain_id = $1
2736 AND pool_identifier = $2
2737 AND snapshot_block = $3
2738 AND snapshot_transaction_index = $4
2739 AND snapshot_log_index = $5
2740 ",
2741 )
2742 .bind(chain_id as i32)
2743 .bind(pool_identifier.as_ref())
2744 .bind(snapshot_block as i64)
2745 .bind(snapshot_transaction_index as i32)
2746 .bind(snapshot_log_index as i32)
2747 .fetch_all(&self.pool)
2748 .await
2749 .map_err(|e| anyhow::anyhow!("Failed to load pool ticks: {e}"))?;
2750
2751 rows.iter()
2752 .map(|row| {
2753 let tick = PoolTick::new(
2754 row.get("tick_value"),
2755 row.get::<String, _>("liquidity_gross").parse()?,
2756 row.get::<String, _>("liquidity_net").parse()?,
2757 row.get::<String, _>("fee_growth_outside_0").parse()?,
2758 row.get::<String, _>("fee_growth_outside_1").parse()?,
2759 row.get("initialized"),
2760 u64::try_from(row.get::<i64, _>("last_updated_block"))
2761 .with_context(|| "Pool tick last updated block is negative")?,
2762 );
2763 Ok(tick)
2764 })
2765 .collect()
2766 }
2767
2768 pub fn stream_pool_events<'a>(
2782 &'a self,
2783 chain: SharedChain,
2784 dex: SharedDex,
2785 instrument_id: InstrumentId,
2786 pool_identifier: PoolIdentifier,
2787 from_position: Option<BlockPosition>,
2788 to_block: Option<u64>,
2789 ) -> Pin<Box<dyn Stream<Item = Result<DexPoolData, anyhow::Error>> + Send + 'a>> {
2790 const QUERY_ALL: &str = "
2791 SELECT events.*,
2792 COALESCE(
2793 (SELECT hash FROM pool_event_block WHERE pool_event_block.chain_id = events.chain_id AND pool_event_block.number = events.block),
2794 (SELECT hash FROM block WHERE block.chain_id = events.chain_id AND block.number = events.block)
2795 ) as block_hash
2796 FROM (
2797 (SELECT
2798 'swap' as event_type,
2799 chain_id,
2800 pool_identifier,
2801 block,
2802 transaction_hash,
2803 transaction_index,
2804 log_index,
2805 COALESCE(
2806 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_swap_event.chain_id AND block.number = pool_swap_event.block),
2807 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_swap_event.chain_id AND pool_event_block.number = pool_swap_event.block)
2808 ) as block_timestamp,
2809 sender,
2810 recipient,
2811 NULL::TEXT as owner,
2812 sqrt_price_x96::TEXT,
2813 liquidity::TEXT as swap_liquidity,
2814 tick as swap_tick,
2815 amount0::TEXT as swap_amount0,
2816 amount1::TEXT as swap_amount1,
2817 NULL::TEXT as position_liquidity,
2818 NULL::TEXT as amount0,
2819 NULL::TEXT as amount1,
2820 NULL::INT as tick_lower,
2821 NULL::INT as tick_upper,
2822 NULL::TEXT as liquidity_event_type,
2823 NULL::TEXT as flash_amount0,
2824 NULL::TEXT as flash_amount1,
2825 NULL::TEXT as flash_paid0,
2826 NULL::TEXT as flash_paid1,
2827 NULL::INTEGER as fee_protocol0_new,
2828 NULL::INTEGER as fee_protocol1_new
2829 FROM pool_swap_event
2830 WHERE chain_id = $1 AND pool_identifier = $2
2831 AND ($3::BIGINT IS NULL OR block <= $3))
2832 UNION ALL
2833 (SELECT
2834 'liquidity' as event_type,
2835 chain_id,
2836 pool_identifier,
2837 block,
2838 transaction_hash,
2839 transaction_index,
2840 log_index,
2841 COALESCE(
2842 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_liquidity_event.chain_id AND block.number = pool_liquidity_event.block),
2843 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_liquidity_event.chain_id AND pool_event_block.number = pool_liquidity_event.block)
2844 ) as block_timestamp,
2845 sender,
2846 NULL::TEXT as recipient,
2847 owner,
2848 NULL::text as sqrt_price_x96,
2849 NULL::TEXT as swap_liquidity,
2850 NULL::INT as swap_tick,
2851 amount0::TEXT as swap_amount0,
2852 amount1::TEXT as swap_amount1,
2853 position_liquidity::TEXT,
2854 amount0::TEXT,
2855 amount1::TEXT,
2856 tick_lower::INT,
2857 tick_upper::INT,
2858 event_type as liquidity_event_type,
2859 NULL::TEXT as flash_amount0,
2860 NULL::TEXT as flash_amount1,
2861 NULL::TEXT as flash_paid0,
2862 NULL::TEXT as flash_paid1,
2863 NULL::INTEGER as fee_protocol0_new,
2864 NULL::INTEGER as fee_protocol1_new
2865 FROM pool_liquidity_event
2866 WHERE chain_id = $1 AND pool_identifier = $2
2867 AND ($3::BIGINT IS NULL OR block <= $3))
2868 UNION ALL
2869 (SELECT
2870 'collect' as event_type,
2871 chain_id,
2872 pool_identifier,
2873 block,
2874 transaction_hash,
2875 transaction_index,
2876 log_index,
2877 COALESCE(
2878 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_collect_event.chain_id AND block.number = pool_collect_event.block),
2879 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_collect_event.chain_id AND pool_event_block.number = pool_collect_event.block)
2880 ) as block_timestamp,
2881 NULL::TEXT as sender,
2882 NULL::TEXT as recipient,
2883 owner,
2884 NULL::TEXT as sqrt_price_x96,
2885 NULL::TEXT as swap_liquidity,
2886 NULL::INT AS swap_tick,
2887 amount0::TEXT as swap_amount0,
2888 amount1::TEXT as swap_amount1,
2889 NULL::TEXT as position_liquidity,
2890 amount0::TEXT,
2891 amount1::TEXT,
2892 tick_lower::INT,
2893 tick_upper::INT,
2894 NULL::TEXT as liquidity_event_type,
2895 NULL::TEXT as flash_amount0,
2896 NULL::TEXT as flash_amount1,
2897 NULL::TEXT as flash_paid0,
2898 NULL::TEXT as flash_paid1,
2899 NULL::INTEGER as fee_protocol0_new,
2900 NULL::INTEGER as fee_protocol1_new
2901 FROM pool_collect_event
2902 WHERE chain_id = $1 AND pool_identifier = $2
2903 AND ($3::BIGINT IS NULL OR block <= $3))
2904 UNION ALL
2905 (SELECT
2906 'fee_protocol_update' as event_type,
2907 chain_id,
2908 pool_identifier,
2909 block,
2910 transaction_hash,
2911 transaction_index,
2912 log_index,
2913 COALESCE(
2914 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_fee_protocol_update_event.chain_id AND block.number = pool_fee_protocol_update_event.block),
2915 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_fee_protocol_update_event.chain_id AND pool_event_block.number = pool_fee_protocol_update_event.block)
2916 ) as block_timestamp,
2917 NULL::TEXT as sender,
2918 NULL::TEXT as recipient,
2919 NULL::TEXT as owner,
2920 NULL::TEXT as sqrt_price_x96,
2921 NULL::TEXT as swap_liquidity,
2922 NULL::INT AS swap_tick,
2923 NULL::TEXT as swap_amount0,
2924 NULL::TEXT as swap_amount1,
2925 NULL::TEXT as position_liquidity,
2926 NULL::TEXT as amount0,
2927 NULL::TEXT as amount1,
2928 NULL::INT as tick_lower,
2929 NULL::INT as tick_upper,
2930 NULL::TEXT as liquidity_event_type,
2931 NULL::TEXT as flash_amount0,
2932 NULL::TEXT as flash_amount1,
2933 NULL::TEXT as flash_paid0,
2934 NULL::TEXT as flash_paid1,
2935 fee_protocol0_new::INTEGER,
2936 fee_protocol1_new::INTEGER
2937 FROM pool_fee_protocol_update_event
2938 WHERE chain_id = $1 AND pool_identifier = $2
2939 AND ($3::BIGINT IS NULL OR block <= $3))
2940 UNION ALL
2941 (SELECT
2942 'fee_protocol_collect' as event_type,
2943 chain_id,
2944 pool_identifier,
2945 block,
2946 transaction_hash,
2947 transaction_index,
2948 log_index,
2949 COALESCE(
2950 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_fee_protocol_collect_event.chain_id AND block.number = pool_fee_protocol_collect_event.block),
2951 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_fee_protocol_collect_event.chain_id AND pool_event_block.number = pool_fee_protocol_collect_event.block)
2952 ) as block_timestamp,
2953 sender,
2954 recipient,
2955 NULL::TEXT as owner,
2956 NULL::TEXT as sqrt_price_x96,
2957 NULL::TEXT as swap_liquidity,
2958 NULL::INT AS swap_tick,
2959 NULL::TEXT as swap_amount0,
2960 NULL::TEXT as swap_amount1,
2961 NULL::TEXT as position_liquidity,
2962 amount0::TEXT,
2963 amount1::TEXT,
2964 NULL::INT as tick_lower,
2965 NULL::INT as tick_upper,
2966 NULL::TEXT as liquidity_event_type,
2967 NULL::TEXT as flash_amount0,
2968 NULL::TEXT as flash_amount1,
2969 NULL::TEXT as flash_paid0,
2970 NULL::TEXT as flash_paid1,
2971 NULL::INTEGER as fee_protocol0_new,
2972 NULL::INTEGER as fee_protocol1_new
2973 FROM pool_fee_protocol_collect_event
2974 WHERE chain_id = $1 AND pool_identifier = $2
2975 AND ($3::BIGINT IS NULL OR block <= $3))
2976 UNION ALL
2977 (SELECT
2978 'flash' as event_type,
2979 chain_id,
2980 pool_identifier,
2981 block,
2982 transaction_hash,
2983 transaction_index,
2984 log_index,
2985 COALESCE(
2986 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_flash_event.chain_id AND block.number = pool_flash_event.block),
2987 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_flash_event.chain_id AND pool_event_block.number = pool_flash_event.block)
2988 ) as block_timestamp,
2989 sender,
2990 recipient,
2991 NULL::TEXT as owner,
2992 NULL::TEXT as sqrt_price_x96,
2993 NULL::TEXT as swap_liquidity,
2994 NULL::INT AS swap_tick,
2995 NULL::TEXT as swap_amount0,
2996 NULL::TEXT as swap_amount1,
2997 NULL::TEXT as position_liquidity,
2998 NULL::TEXT as amount0,
2999 NULL::TEXT as amount1,
3000 NULL::INT as tick_lower,
3001 NULL::INT as tick_upper,
3002 NULL::TEXT as liquidity_event_type,
3003 amount0::TEXT as flash_amount0,
3004 amount1::TEXT as flash_amount1,
3005 paid0::TEXT as flash_paid0,
3006 paid1::TEXT as flash_paid1,
3007 NULL::INTEGER as fee_protocol0_new,
3008 NULL::INTEGER as fee_protocol1_new
3009 FROM pool_flash_event
3010 WHERE chain_id = $1 AND pool_identifier = $2
3011 AND ($3::BIGINT IS NULL OR block <= $3))
3012 ) AS events
3013 ORDER BY events.block, events.transaction_index, events.log_index";
3014
3015 const QUERY_FROM_POSITION: &str = "
3016 SELECT events.*,
3017 COALESCE(
3018 (SELECT hash FROM pool_event_block WHERE pool_event_block.chain_id = events.chain_id AND pool_event_block.number = events.block),
3019 (SELECT hash FROM block WHERE block.chain_id = events.chain_id AND block.number = events.block)
3020 ) as block_hash
3021 FROM (
3022 (SELECT
3023 'swap' as event_type,
3024 chain_id,
3025 pool_identifier,
3026 block,
3027 transaction_hash,
3028 transaction_index,
3029 log_index,
3030 COALESCE(
3031 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_swap_event.chain_id AND block.number = pool_swap_event.block),
3032 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_swap_event.chain_id AND pool_event_block.number = pool_swap_event.block)
3033 ) as block_timestamp,
3034 sender,
3035 recipient,
3036 NULL::TEXT as owner,
3037 sqrt_price_x96::TEXT,
3038 liquidity::TEXT as swap_liquidity,
3039 tick as swap_tick,
3040 amount0::TEXT as swap_amount0,
3041 amount1::TEXT as swap_amount1,
3042 NULL::TEXT as position_liquidity,
3043 NULL::TEXT as amount0,
3044 NULL::TEXT as amount1,
3045 NULL::INT as tick_lower,
3046 NULL::INT as tick_upper,
3047 NULL::TEXT as liquidity_event_type,
3048 NULL::TEXT as flash_amount0,
3049 NULL::TEXT as flash_amount1,
3050 NULL::TEXT as flash_paid0,
3051 NULL::TEXT as flash_paid1,
3052 NULL::INTEGER as fee_protocol0_new,
3053 NULL::INTEGER as fee_protocol1_new
3054 FROM pool_swap_event
3055 WHERE chain_id = $1 AND pool_identifier = $2
3056 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3057 AND ($6::BIGINT IS NULL OR block <= $6))
3058 UNION ALL
3059 (SELECT
3060 'liquidity' as event_type,
3061 chain_id,
3062 pool_identifier,
3063 block,
3064 transaction_hash,
3065 transaction_index,
3066 log_index,
3067 COALESCE(
3068 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_liquidity_event.chain_id AND block.number = pool_liquidity_event.block),
3069 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_liquidity_event.chain_id AND pool_event_block.number = pool_liquidity_event.block)
3070 ) as block_timestamp,
3071 sender,
3072 NULL::TEXT as recipient,
3073 owner,
3074 NULL::text as sqrt_price_x96,
3075 NULL::TEXT as swap_liquidity,
3076 NULL::INT as swap_tick,
3077 amount0::TEXT as swap_amount0,
3078 amount1::TEXT as swap_amount1,
3079 position_liquidity::TEXT,
3080 amount0::TEXT,
3081 amount1::TEXT,
3082 tick_lower::INT,
3083 tick_upper::INT,
3084 event_type as liquidity_event_type,
3085 NULL::TEXT as flash_amount0,
3086 NULL::TEXT as flash_amount1,
3087 NULL::TEXT as flash_paid0,
3088 NULL::TEXT as flash_paid1,
3089 NULL::INTEGER as fee_protocol0_new,
3090 NULL::INTEGER as fee_protocol1_new
3091 FROM pool_liquidity_event
3092 WHERE chain_id = $1 AND pool_identifier = $2
3093 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3094 AND ($6::BIGINT IS NULL OR block <= $6))
3095 UNION ALL
3096 (SELECT
3097 'collect' as event_type,
3098 chain_id,
3099 pool_identifier,
3100 block,
3101 transaction_hash,
3102 transaction_index,
3103 log_index,
3104 COALESCE(
3105 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_collect_event.chain_id AND block.number = pool_collect_event.block),
3106 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_collect_event.chain_id AND pool_event_block.number = pool_collect_event.block)
3107 ) as block_timestamp,
3108 NULL::TEXT as sender,
3109 NULL::TEXT as recipient,
3110 owner,
3111 NULL::TEXT as sqrt_price_x96,
3112 NULL::TEXT as swap_liquidity,
3113 NULL::INT AS swap_tick,
3114 amount0::TEXT as swap_amount0,
3115 amount1::TEXT as swap_amount1,
3116 NULL::TEXT as position_liquidity,
3117 amount0::TEXT,
3118 amount1::TEXT,
3119 tick_lower::INT,
3120 tick_upper::INT,
3121 NULL::TEXT as liquidity_event_type,
3122 NULL::TEXT as flash_amount0,
3123 NULL::TEXT as flash_amount1,
3124 NULL::TEXT as flash_paid0,
3125 NULL::TEXT as flash_paid1,
3126 NULL::INTEGER as fee_protocol0_new,
3127 NULL::INTEGER as fee_protocol1_new
3128 FROM pool_collect_event
3129 WHERE chain_id = $1 AND pool_identifier = $2
3130 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3131 AND ($6::BIGINT IS NULL OR block <= $6))
3132 UNION ALL
3133 (SELECT
3134 'fee_protocol_update' as event_type,
3135 chain_id,
3136 pool_identifier,
3137 block,
3138 transaction_hash,
3139 transaction_index,
3140 log_index,
3141 COALESCE(
3142 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_fee_protocol_update_event.chain_id AND block.number = pool_fee_protocol_update_event.block),
3143 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_fee_protocol_update_event.chain_id AND pool_event_block.number = pool_fee_protocol_update_event.block)
3144 ) as block_timestamp,
3145 NULL::TEXT as sender,
3146 NULL::TEXT as recipient,
3147 NULL::TEXT as owner,
3148 NULL::TEXT as sqrt_price_x96,
3149 NULL::TEXT as swap_liquidity,
3150 NULL::INT AS swap_tick,
3151 NULL::TEXT as swap_amount0,
3152 NULL::TEXT as swap_amount1,
3153 NULL::TEXT as position_liquidity,
3154 NULL::TEXT as amount0,
3155 NULL::TEXT as amount1,
3156 NULL::INT as tick_lower,
3157 NULL::INT as tick_upper,
3158 NULL::TEXT as liquidity_event_type,
3159 NULL::TEXT as flash_amount0,
3160 NULL::TEXT as flash_amount1,
3161 NULL::TEXT as flash_paid0,
3162 NULL::TEXT as flash_paid1,
3163 fee_protocol0_new::INTEGER,
3164 fee_protocol1_new::INTEGER
3165 FROM pool_fee_protocol_update_event
3166 WHERE chain_id = $1 AND pool_identifier = $2
3167 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3168 AND ($6::BIGINT IS NULL OR block <= $6))
3169 UNION ALL
3170 (SELECT
3171 'fee_protocol_collect' as event_type,
3172 chain_id,
3173 pool_identifier,
3174 block,
3175 transaction_hash,
3176 transaction_index,
3177 log_index,
3178 COALESCE(
3179 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_fee_protocol_collect_event.chain_id AND block.number = pool_fee_protocol_collect_event.block),
3180 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_fee_protocol_collect_event.chain_id AND pool_event_block.number = pool_fee_protocol_collect_event.block)
3181 ) as block_timestamp,
3182 sender,
3183 recipient,
3184 NULL::TEXT as owner,
3185 NULL::TEXT as sqrt_price_x96,
3186 NULL::TEXT as swap_liquidity,
3187 NULL::INT AS swap_tick,
3188 NULL::TEXT as swap_amount0,
3189 NULL::TEXT as swap_amount1,
3190 NULL::TEXT as position_liquidity,
3191 amount0::TEXT,
3192 amount1::TEXT,
3193 NULL::INT as tick_lower,
3194 NULL::INT as tick_upper,
3195 NULL::TEXT as liquidity_event_type,
3196 NULL::TEXT as flash_amount0,
3197 NULL::TEXT as flash_amount1,
3198 NULL::TEXT as flash_paid0,
3199 NULL::TEXT as flash_paid1,
3200 NULL::INTEGER as fee_protocol0_new,
3201 NULL::INTEGER as fee_protocol1_new
3202 FROM pool_fee_protocol_collect_event
3203 WHERE chain_id = $1 AND pool_identifier = $2
3204 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3205 AND ($6::BIGINT IS NULL OR block <= $6))
3206 UNION ALL
3207 (SELECT
3208 'flash' as event_type,
3209 chain_id,
3210 pool_identifier,
3211 block,
3212 transaction_hash,
3213 transaction_index,
3214 log_index,
3215 COALESCE(
3216 (SELECT timestamp::TEXT FROM block WHERE block.chain_id = pool_flash_event.chain_id AND block.number = pool_flash_event.block),
3217 (SELECT timestamp::TEXT FROM pool_event_block WHERE pool_event_block.chain_id = pool_flash_event.chain_id AND pool_event_block.number = pool_flash_event.block)
3218 ) as block_timestamp,
3219 sender,
3220 recipient,
3221 NULL::TEXT as owner,
3222 NULL::TEXT as sqrt_price_x96,
3223 NULL::TEXT as swap_liquidity,
3224 NULL::INT AS swap_tick,
3225 NULL::TEXT as swap_amount0,
3226 NULL::TEXT as swap_amount1,
3227 NULL::TEXT as position_liquidity,
3228 NULL::TEXT as amount0,
3229 NULL::TEXT as amount1,
3230 NULL::INT as tick_lower,
3231 NULL::INT as tick_upper,
3232 NULL::TEXT as liquidity_event_type,
3233 amount0::TEXT as flash_amount0,
3234 amount1::TEXT as flash_amount1,
3235 paid0::TEXT as flash_paid0,
3236 paid1::TEXT as flash_paid1,
3237 NULL::INTEGER as fee_protocol0_new,
3238 NULL::INTEGER as fee_protocol1_new
3239 FROM pool_flash_event
3240 WHERE chain_id = $1 AND pool_identifier = $2
3241 AND (block > $3 OR (block = $3 AND transaction_index > $4) OR (block = $3 AND transaction_index = $4 AND log_index > $5))
3242 AND ($6::BIGINT IS NULL OR block <= $6))
3243 ) AS events
3244 ORDER BY events.block, events.transaction_index, events.log_index";
3245
3246 let query = if let Some(pos) = from_position {
3248 sqlx::query(QUERY_FROM_POSITION)
3249 .bind(chain.chain_id as i32)
3250 .bind(pool_identifier.to_string())
3251 .bind(pos.number as i64)
3252 .bind(pos.transaction_index as i32)
3253 .bind(pos.log_index as i32)
3254 .bind(to_block.map(|block| block as i64))
3255 .fetch(&self.pool)
3256 } else {
3257 sqlx::query(QUERY_ALL)
3258 .bind(chain.chain_id as i32)
3259 .bind(pool_identifier.to_string())
3260 .bind(to_block.map(|block| block as i64))
3261 .fetch(&self.pool)
3262 };
3263
3264 let stream = query.map(move |row_result| match row_result {
3266 Ok(row) => {
3267 transform_row_to_dex_pool_data(&row, chain.clone(), dex.clone(), instrument_id)
3268 .map_err(|e| anyhow::anyhow!("Steam pool event transform error: {e}"))
3269 }
3270 Err(e) => Err(anyhow::anyhow!("Stream pool events database error: {e}")),
3271 });
3272
3273 Box::pin(stream)
3274 }
3275
3276 #[expect(
3288 clippy::too_many_arguments,
3289 reason = "the parameters mirror the persisted execution transaction fields"
3290 )]
3291 pub async fn add_execution_transaction(
3292 &self,
3293 chain_id: u32,
3294 wallet_address: &str,
3295 nonce: u64,
3296 transaction_hash: &str,
3297 purpose: &str,
3298 status: &str,
3299 client_order_id: Option<&str>,
3300 ) -> anyhow::Result<()> {
3301 let result = sqlx::query(
3302 "
3303 INSERT INTO execution_transaction (
3304 chain_id,
3305 wallet_address,
3306 nonce,
3307 transaction_hash,
3308 purpose,
3309 status,
3310 client_order_id
3311 )
3312 VALUES ($1, $2, $3, $4, $5, $6, $7)
3313 ON CONFLICT (chain_id, transaction_hash)
3314 DO UPDATE SET transaction_hash = EXCLUDED.transaction_hash
3315 WHERE execution_transaction.wallet_address = EXCLUDED.wallet_address
3316 AND execution_transaction.nonce = EXCLUDED.nonce
3317 AND execution_transaction.purpose = EXCLUDED.purpose
3318 AND execution_transaction.status = EXCLUDED.status
3319 AND execution_transaction.client_order_id IS NOT DISTINCT FROM EXCLUDED.client_order_id
3320 ",
3321 )
3322 .bind(chain_id as i32)
3323 .bind(wallet_address)
3324 .bind(nonce as i64)
3325 .bind(transaction_hash)
3326 .bind(purpose)
3327 .bind(status)
3328 .bind(client_order_id)
3329 .execute(&self.pool)
3330 .await
3331 .map_err(|e| anyhow::anyhow!("Failed to insert into execution_transaction table: {e}"))?;
3332
3333 anyhow::ensure!(
3334 result.rows_affected() == 1,
3335 "Execution transaction {transaction_hash} conflicts with its persisted record"
3336 );
3337 Ok(())
3338 }
3339
3340 pub async fn ensure_execution_transaction_schema(&self) -> anyhow::Result<()> {
3351 let mut transaction = self
3352 .pool
3353 .begin()
3354 .await
3355 .map_err(|e| anyhow::anyhow!("Failed to start execution schema migration: {e}"))?;
3356
3357 sqlx::query("LOCK TABLE execution_transaction IN ACCESS EXCLUSIVE MODE")
3358 .execute(&mut *transaction)
3359 .await
3360 .map_err(|e| anyhow::anyhow!("Failed to lock legacy execution transactions: {e}"))?;
3361
3362 for statement in [
3363 "
3364 ALTER TABLE execution_transaction
3365 ADD COLUMN IF NOT EXISTS client_order_id TEXT
3366 ",
3367 "
3368 ALTER TABLE execution_transaction
3369 ADD COLUMN IF NOT EXISTS wallet_address TEXT
3370 ",
3371 "
3372 ALTER TABLE execution_transaction
3373 ALTER COLUMN wallet_address DROP NOT NULL
3374 ",
3375 "
3376 CREATE TABLE IF NOT EXISTS execution_schema_version (
3377 component TEXT PRIMARY KEY,
3378 version SMALLINT NOT NULL CHECK (version > 0)
3379 )
3380 ",
3381 "
3382 CREATE TABLE IF NOT EXISTS execution_intent (
3383 id BIGSERIAL PRIMARY KEY,
3384 schema_version SMALLINT NOT NULL,
3385 chain_id INTEGER NOT NULL REFERENCES chain(chain_id) ON DELETE RESTRICT,
3386 wallet_address TEXT NOT NULL,
3387 nonce BIGINT,
3388 purpose TEXT NOT NULL,
3389 status TEXT NOT NULL,
3390 client_order_id TEXT,
3391 trader_id TEXT,
3392 strategy_id TEXT,
3393 account_id TEXT,
3394 instrument_id TEXT,
3395 pool_address TEXT,
3396 transaction_to TEXT NOT NULL,
3397 transaction_input TEXT NOT NULL,
3398 transaction_value TEXT NOT NULL,
3399 amount_in TEXT,
3400 created_block BIGINT NOT NULL,
3401 acknowledgement_emitted BOOLEAN NOT NULL DEFAULT FALSE,
3402 fill_emitted BOOLEAN NOT NULL DEFAULT FALSE,
3403 terminal_emitted BOOLEAN NOT NULL DEFAULT FALSE,
3404 active BOOLEAN NOT NULL DEFAULT TRUE,
3405 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3406 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3407 CHECK (schema_version = 2),
3408 CHECK (nonce IS NULL OR nonce >= 0),
3409 CHECK (created_block >= 0),
3410 CHECK (status IN (
3411 'prepared', 'signed', 'broadcast', 'included', 'finalized',
3412 'reverted', 'replaced', 'dropped', 'reorged', 'recoverable'
3413 )),
3414 CONSTRAINT execution_intent_active_check CHECK (
3415 NOT active
3416 OR status NOT IN ('finalized', 'reverted', 'recoverable')
3417 OR (
3418 status IN ('finalized', 'reverted')
3419 AND NOT (fill_emitted OR terminal_emitted)
3420 )
3421 ),
3422 CHECK (NOT (fill_emitted AND terminal_emitted)),
3423 CHECK (
3424 purpose <> 'swap'
3425 OR (
3426 client_order_id IS NOT NULL
3427 AND trader_id IS NOT NULL
3428 AND strategy_id IS NOT NULL
3429 AND account_id IS NOT NULL
3430 AND instrument_id IS NOT NULL
3431 AND pool_address IS NOT NULL
3432 AND amount_in IS NOT NULL
3433 )
3434 ),
3435 UNIQUE (id, chain_id)
3436 )
3437 ",
3438 "
3439 CREATE UNIQUE INDEX IF NOT EXISTS execution_intent_active_signer_key
3440 ON execution_intent (chain_id, wallet_address)
3441 WHERE active
3442 ",
3443 "
3444 CREATE UNIQUE INDEX IF NOT EXISTS execution_intent_active_nonce_key
3445 ON execution_intent (chain_id, wallet_address, nonce)
3446 WHERE active AND nonce IS NOT NULL
3447 ",
3448 "
3449 CREATE UNIQUE INDEX IF NOT EXISTS execution_intent_client_order_key
3450 ON execution_intent (chain_id, wallet_address, client_order_id)
3451 WHERE client_order_id IS NOT NULL
3452 ",
3453 "
3454 ALTER TABLE execution_intent
3455 DROP CONSTRAINT IF EXISTS execution_intent_active_check
3456 ",
3457 "
3458 ALTER TABLE execution_intent
3459 ADD CONSTRAINT execution_intent_active_check CHECK (
3460 NOT active
3461 OR status NOT IN ('finalized', 'reverted', 'recoverable')
3462 OR (
3463 status IN ('finalized', 'reverted')
3464 AND NOT (fill_emitted OR terminal_emitted)
3465 )
3466 )
3467 ",
3468 "
3469 CREATE TABLE IF NOT EXISTS execution_transaction_hash (
3470 id BIGSERIAL PRIMARY KEY,
3471 intent_id BIGINT NOT NULL,
3472 chain_id INTEGER NOT NULL,
3473 transaction_hash TEXT NOT NULL,
3474 payload_expected BOOLEAN NOT NULL DEFAULT TRUE,
3475 raw_transaction BYTEA,
3476 sealed_transaction BYTEA,
3477 status TEXT NOT NULL,
3478 block_number BIGINT,
3479 block_hash TEXT,
3480 receipt_success BOOLEAN,
3481 gas_used BIGINT,
3482 effective_gas_price TEXT,
3483 current BOOLEAN NOT NULL DEFAULT TRUE,
3484 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3485 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3486 FOREIGN KEY (intent_id, chain_id)
3487 REFERENCES execution_intent(id, chain_id) ON DELETE RESTRICT,
3488 CHECK (block_number IS NULL OR block_number >= 0),
3489 CHECK (gas_used IS NULL OR gas_used >= 0),
3490 CONSTRAINT execution_transaction_raw_size_check
3491 CHECK (raw_transaction IS NULL OR octet_length(raw_transaction) <= 131072),
3492 CONSTRAINT execution_transaction_sealed_size_check
3493 CHECK (sealed_transaction IS NULL OR octet_length(sealed_transaction) <= 131133),
3494 CHECK (status IN (
3495 'signed', 'broadcast', 'included', 'finalized', 'reverted',
3496 'replaced', 'dropped', 'reorged'
3497 )),
3498 UNIQUE (chain_id, transaction_hash),
3499 UNIQUE (intent_id, transaction_hash)
3500 )
3501 ",
3502 "
3503 ALTER TABLE execution_transaction_hash
3504 ADD COLUMN IF NOT EXISTS payload_expected BOOLEAN
3505 ",
3506 "
3507 UPDATE execution_transaction_hash
3508 SET payload_expected = raw_transaction IS NOT NULL
3509 WHERE payload_expected IS NULL
3510 ",
3511 "
3512 ALTER TABLE execution_transaction_hash
3513 ALTER COLUMN payload_expected SET DEFAULT TRUE
3514 ",
3515 "
3516 ALTER TABLE execution_transaction_hash
3517 ALTER COLUMN payload_expected SET NOT NULL
3518 ",
3519 "
3520 ALTER TABLE execution_transaction_hash
3521 ADD COLUMN IF NOT EXISTS sealed_transaction BYTEA
3522 ",
3523 "
3524 ALTER TABLE execution_transaction_hash
3525 DROP CONSTRAINT IF EXISTS execution_transaction_raw_size_check
3526 ",
3527 "
3528 ALTER TABLE execution_transaction_hash
3529 ADD CONSTRAINT execution_transaction_raw_size_check
3530 CHECK (raw_transaction IS NULL OR octet_length(raw_transaction) <= 131072)
3531 ",
3532 "
3533 ALTER TABLE execution_transaction_hash
3534 DROP CONSTRAINT IF EXISTS execution_transaction_sealed_size_check
3535 ",
3536 "
3537 ALTER TABLE execution_transaction_hash
3538 ADD CONSTRAINT execution_transaction_sealed_size_check
3539 CHECK (sealed_transaction IS NULL OR octet_length(sealed_transaction) <= 131133)
3540 ",
3541 "
3542 CREATE TABLE IF NOT EXISTS execution_payload_state (
3543 component TEXT PRIMARY KEY CHECK (component = 'signed_transactions'),
3544 deployment_id TEXT NOT NULL CHECK (deployment_id <> ''),
3545 protocol_version SMALLINT NOT NULL CHECK (protocol_version = 1),
3546 operation TEXT NOT NULL CHECK (operation IN ('migrate', 'ready', 'rewrap', 'rollback')),
3547 active_key_id BYTEA NOT NULL CHECK (octet_length(active_key_id) = 32),
3548 progress_id BIGINT NOT NULL DEFAULT 0 CHECK (progress_id >= 0),
3549 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
3550 )
3551 ",
3552 "
3553 CREATE TABLE IF NOT EXISTS execution_payload_key_state (
3554 key_id BYTEA PRIMARY KEY CHECK (octet_length(key_id) = 32),
3555 seals BIGINT NOT NULL DEFAULT 0 CHECK (seals >= 0 AND seals < 4294967296)
3556 )
3557 ",
3558 "
3559 CREATE UNIQUE INDEX IF NOT EXISTS execution_transaction_hash_current_key
3560 ON execution_transaction_hash (intent_id)
3561 WHERE current
3562 ",
3563 "
3564 CREATE TABLE IF NOT EXISTS execution_transaction_transition (
3565 id BIGSERIAL PRIMARY KEY,
3566 intent_id BIGINT NOT NULL REFERENCES execution_intent(id) ON DELETE RESTRICT,
3567 transaction_hash_id BIGINT REFERENCES execution_transaction_hash(id) ON DELETE RESTRICT,
3568 transition_key TEXT NOT NULL,
3569 from_status TEXT,
3570 to_status TEXT NOT NULL,
3571 block_number BIGINT,
3572 block_hash TEXT,
3573 observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3574 CHECK (block_number IS NULL OR block_number >= 0),
3575 CHECK (to_status IN (
3576 'prepared', 'signed', 'broadcast', 'included', 'finalized',
3577 'reverted', 'replaced', 'dropped', 'reorged', 'recoverable'
3578 )),
3579 UNIQUE (intent_id, transition_key)
3580 )
3581 ",
3582 ] {
3583 sqlx::query(statement)
3584 .execute(&mut *transaction)
3585 .await
3586 .map_err(|e| {
3587 anyhow::anyhow!("Failed to migrate execution_transaction table: {e}")
3588 })?;
3589 }
3590
3591 let installed_version = sqlx::query_scalar::<_, i16>(
3592 "SELECT version FROM execution_schema_version WHERE component = 'evm_execution'",
3593 )
3594 .fetch_optional(&mut *transaction)
3595 .await
3596 .map_err(|e| anyhow::anyhow!("Failed to read execution schema version: {e}"))?;
3597 if let Some(installed_version) = installed_version
3598 && installed_version > EXECUTION_SCHEMA_VERSION
3599 {
3600 anyhow::bail!(
3601 "Execution schema version {} is newer than supported version {EXECUTION_SCHEMA_VERSION}",
3602 installed_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(
5758 &self,
5759 intent: &ExecutionIntentInsert,
5760 ) -> anyhow::Result<ExecutionIntentRow> {
5761 let (transaction, row) = async {
5762 let chain_id = i32::try_from(intent.chain_id).with_context(|| {
5763 format!("Chain ID {} exceeds PostgreSQL INTEGER", intent.chain_id)
5764 })?;
5765 let created_block = i64::try_from(intent.created_block).with_context(|| {
5766 format!(
5767 "Execution creation block {} exceeds PostgreSQL BIGINT",
5768 intent.created_block
5769 )
5770 })?;
5771 let mut transaction = self
5772 .pool
5773 .begin()
5774 .await
5775 .context("failed to start execution intent reservation")?;
5776 Self::lock_execution_signer(&mut transaction, intent.chain_id, &intent.wallet_address)
5777 .await?;
5778 let row = sqlx::query_as::<_, ExecutionIntentRow>(
5779 "
5780 INSERT INTO execution_intent (
5781 schema_version, chain_id, wallet_address, purpose, status,
5782 client_order_id, trader_id, strategy_id, account_id, instrument_id,
5783 pool_address, transaction_to, transaction_input, transaction_value,
5784 amount_in, created_block
5785 )
5786 VALUES ($1, $2, $3, $4, 'prepared', $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
5787 RETURNING
5788 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
5789 client_order_id, trader_id, strategy_id, account_id, instrument_id,
5790 pool_address, transaction_to, transaction_input, transaction_value,
5791 amount_in, created_block, acknowledgement_emitted, fill_emitted,
5792 terminal_emitted, active
5793 ",
5794 )
5795 .bind(EXECUTION_SCHEMA_VERSION)
5796 .bind(chain_id)
5797 .bind(&intent.wallet_address)
5798 .bind(&intent.purpose)
5799 .bind(&intent.client_order_id)
5800 .bind(&intent.trader_id)
5801 .bind(&intent.strategy_id)
5802 .bind(&intent.account_id)
5803 .bind(&intent.instrument_id)
5804 .bind(&intent.pool_address)
5805 .bind(&intent.transaction_to)
5806 .bind(&intent.transaction_input)
5807 .bind(&intent.transaction_value)
5808 .bind(&intent.amount_in)
5809 .bind(created_block)
5810 .fetch_one(&mut *transaction)
5811 .await
5812 .context("failed to insert execution intent reservation")?;
5813
5814 sqlx::query(
5815 "
5816 INSERT INTO execution_transaction_transition (
5817 intent_id, transition_key, to_status, block_number
5818 ) VALUES ($1, 'prepared', 'prepared', $2)
5819 ",
5820 )
5821 .bind(row.id)
5822 .bind(created_block)
5823 .execute(&mut *transaction)
5824 .await
5825 .context("failed to record prepared execution intent")?;
5826
5827 Ok::<_, anyhow::Error>((transaction, row))
5828 }
5829 .await
5830 .map_err(|source| {
5831 anyhow::Error::new(ExecutionIntentReservationError {
5832 stage: ExecutionIntentReservationStage::BeforeCommit,
5833 source,
5834 })
5835 })?;
5836 transaction.commit().await.map_err(|e| {
5837 anyhow::Error::new(ExecutionIntentReservationError {
5838 stage: ExecutionIntentReservationStage::Commit,
5839 source: anyhow::Error::new(e)
5840 .context("failed to commit execution intent reservation"),
5841 })
5842 })?;
5843 Ok(row)
5844 }
5845
5846 pub async fn assign_execution_intent_nonce(
5855 &self,
5856 intent_id: i64,
5857 nonce: u64,
5858 ) -> anyhow::Result<()> {
5859 let nonce_db = i64::try_from(nonce)
5860 .with_context(|| format!("Execution nonce {nonce} exceeds PostgreSQL BIGINT"))?;
5861 let result = sqlx::query(
5862 "
5863 UPDATE execution_intent
5864 SET nonce = $2, updated_at = NOW()
5865 WHERE id = $1
5866 AND status = 'prepared'
5867 AND (nonce IS NULL OR nonce = $2)
5868 ",
5869 )
5870 .bind(intent_id)
5871 .bind(nonce_db)
5872 .execute(&self.pool)
5873 .await
5874 .map_err(|e| anyhow::anyhow!("Failed to assign nonce {nonce} to execution intent: {e}"))?;
5875 anyhow::ensure!(
5876 result.rows_affected() == 1,
5877 "Execution intent {intent_id} is not prepared for nonce {nonce}"
5878 );
5879 Ok(())
5880 }
5881
5882 pub(crate) async fn assign_execution_intent_nonce_verified(
5889 &self,
5890 assignment: &ExecutionNonceAssignment<'_>,
5891 ) -> anyhow::Result<()> {
5892 anyhow::ensure!(
5893 !assignment.decisions.is_empty(),
5894 "Verified nonce assignment requires decision evidence"
5895 );
5896 anyhow::ensure!(
5897 assignment.provider_ids.len() == 3 && assignment.operator_ids.len() == 3,
5898 "Verified nonce assignment requires exactly three provider and operator IDs"
5899 );
5900 anyhow::ensure!(
5901 assignment.failure_domain_ids.len() >= 3,
5902 "Verified nonce assignment requires the configured failure domains"
5903 );
5904 let chain_id = i32::try_from(assignment.chain_id)
5905 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
5906 let nonce =
5907 i64::try_from(assignment.nonce).context("Execution nonce exceeds PostgreSQL BIGINT")?;
5908 let mut transaction = self
5909 .pool
5910 .begin()
5911 .await
5912 .map_err(|e| anyhow::anyhow!("Failed to start verified nonce assignment: {e}"))?;
5913 let (manifest_version, manifest_digest, next_nonce, revision) =
5914 sqlx::query_as::<_, (String, String, i64, i64)>(
5915 "
5916 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
5917 FROM execution_verification_nonce
5918 WHERE chain_id = $1 AND wallet_address = $2
5919 FOR UPDATE
5920 ",
5921 )
5922 .bind(chain_id)
5923 .bind(assignment.wallet_address)
5924 .fetch_optional(&mut *transaction)
5925 .await
5926 .map_err(|e| anyhow::anyhow!("Failed to lock canonical nonce ledger: {e}"))?
5927 .ok_or_else(|| anyhow::anyhow!("Canonical nonce ledger is not initialized"))?;
5928 anyhow::ensure!(
5929 manifest_version == assignment.manifest_version
5930 && manifest_digest == assignment.manifest_digest,
5931 "Verified nonce assignment manifest identity changed"
5932 );
5933 anyhow::ensure!(
5934 next_nonce == nonce,
5935 "Execution nonce {} does not match canonical nonce {next_nonce}",
5936 assignment.nonce
5937 );
5938
5939 let (intent_chain_id, intent_wallet, intent_nonce, intent_status, intent_active) =
5940 sqlx::query_as::<_, (i32, String, Option<i64>, String, bool)>(
5941 "
5942 SELECT chain_id, wallet_address, nonce, status, active
5943 FROM execution_intent
5944 WHERE id = $1
5945 FOR UPDATE
5946 ",
5947 )
5948 .bind(assignment.intent_id)
5949 .fetch_optional(&mut *transaction)
5950 .await
5951 .map_err(|e| {
5952 anyhow::anyhow!("Failed to lock execution intent for nonce assignment: {e}")
5953 })?
5954 .ok_or_else(|| {
5955 anyhow::anyhow!("Execution intent {} was not found", assignment.intent_id)
5956 })?;
5957 anyhow::ensure!(
5958 intent_chain_id == chain_id
5959 && intent_wallet == assignment.wallet_address
5960 && intent_status == "prepared"
5961 && intent_active
5962 && intent_nonce.is_none_or(|assigned| assigned == nonce),
5963 "Execution intent {} cannot own canonical nonce {}",
5964 assignment.intent_id,
5965 assignment.nonce
5966 );
5967
5968 for (index, decision) in assignment.decisions.iter().enumerate() {
5969 let height_start = decision
5970 .height_start
5971 .map(i64::try_from)
5972 .transpose()
5973 .context("Verification height exceeds PostgreSQL BIGINT")?;
5974 let height_end = decision
5975 .height_end
5976 .map(i64::try_from)
5977 .transpose()
5978 .context("Verification height exceeds PostgreSQL BIGINT")?;
5979 let transition_key = format!(
5980 "pre_sign:{}:{}:{index}",
5981 assignment.intent_id, decision.read_class
5982 );
5983 sqlx::query(
5984 "
5985 INSERT INTO execution_verification_decision (
5986 intent_id, nonce, decision_class, read_class, height_start, height_end,
5987 manifest_version, manifest_digest, provider_ids, operator_ids,
5988 failure_domain_ids, response_class, normalized_value_digest,
5989 nonce_revision, outcome, transition_key
5990 )
5991 VALUES (
5992 $1, $2, 'pre_sign', $3, $4, $5, $6, $7, $8, $9, $10,
5993 'all_valid', $11, $12, 'verified', $13
5994 )
5995 ",
5996 )
5997 .bind(assignment.intent_id)
5998 .bind(nonce)
5999 .bind(decision.read_class)
6000 .bind(height_start)
6001 .bind(height_end)
6002 .bind(assignment.manifest_version)
6003 .bind(assignment.manifest_digest)
6004 .bind(assignment.provider_ids)
6005 .bind(assignment.operator_ids)
6006 .bind(assignment.failure_domain_ids)
6007 .bind(&decision.normalized_value_digest)
6008 .bind(revision)
6009 .bind(transition_key)
6010 .execute(&mut *transaction)
6011 .await
6012 .map_err(|e| anyhow::anyhow!("Failed to persist pre-sign verification: {e}"))?;
6013 }
6014
6015 let result = sqlx::query(
6016 "
6017 UPDATE execution_intent
6018 SET nonce = $2, updated_at = NOW()
6019 WHERE id = $1
6020 AND status = 'prepared'
6021 AND active
6022 AND (nonce IS NULL OR nonce = $2)
6023 ",
6024 )
6025 .bind(assignment.intent_id)
6026 .bind(nonce)
6027 .execute(&mut *transaction)
6028 .await
6029 .map_err(|e| anyhow::anyhow!("Failed to assign verified execution nonce: {e}"))?;
6030 anyhow::ensure!(
6031 result.rows_affected() == 1,
6032 "Execution intent {} is not prepared for canonical nonce {}",
6033 assignment.intent_id,
6034 assignment.nonce
6035 );
6036 transaction
6037 .commit()
6038 .await
6039 .map_err(|e| anyhow::anyhow!("Failed to commit verified nonce assignment: {e}"))?;
6040 Ok(())
6041 }
6042
6043 pub(crate) async fn record_execution_verification_batch(
6045 &self,
6046 batch: &ExecutionVerificationBatch<'_>,
6047 ) -> anyhow::Result<()> {
6048 anyhow::ensure!(
6049 !batch.decision_class.trim().is_empty() && !batch.decisions.is_empty(),
6050 "Verified action requires a decision class and evidence"
6051 );
6052 anyhow::ensure!(
6053 batch.provider_ids.len() == 3
6054 && batch.operator_ids.len() == 3
6055 && batch.failure_domain_ids.len() >= 3,
6056 "Verified action requires the configured provider identities"
6057 );
6058 let chain_id = i32::try_from(batch.chain_id)
6059 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
6060 let nonce =
6061 i64::try_from(batch.nonce).context("Execution nonce exceeds PostgreSQL BIGINT")?;
6062 let mut transaction = self
6063 .pool
6064 .begin()
6065 .await
6066 .map_err(|e| anyhow::anyhow!("Failed to start verified action evidence: {e}"))?;
6067 let (manifest_version, manifest_digest, revision) =
6068 sqlx::query_as::<_, (String, String, i64)>(
6069 "
6070 SELECT manifest_version, manifest_digest, revision
6071 FROM execution_verification_nonce
6072 WHERE chain_id = $1 AND wallet_address = $2
6073 FOR SHARE
6074 ",
6075 )
6076 .bind(chain_id)
6077 .bind(batch.wallet_address)
6078 .fetch_optional(&mut *transaction)
6079 .await
6080 .map_err(|e| anyhow::anyhow!("Failed to read verified action nonce ledger: {e}"))?
6081 .ok_or_else(|| anyhow::anyhow!("Canonical nonce ledger is not initialized"))?;
6082 anyhow::ensure!(
6083 manifest_version == batch.manifest_version && manifest_digest == batch.manifest_digest,
6084 "Verified action manifest identity changed"
6085 );
6086 let intent_nonce = sqlx::query_scalar::<_, Option<i64>>(
6087 "
6088 SELECT nonce
6089 FROM execution_intent
6090 WHERE id = $1 AND chain_id = $2 AND wallet_address = $3 AND active
6091 FOR UPDATE
6092 ",
6093 )
6094 .bind(batch.intent_id)
6095 .bind(chain_id)
6096 .bind(batch.wallet_address)
6097 .fetch_optional(&mut *transaction)
6098 .await
6099 .map_err(|e| anyhow::anyhow!("Failed to lock intent for verified action: {e}"))?
6100 .ok_or_else(|| anyhow::anyhow!("Active verified-action intent was not found"))?;
6101 anyhow::ensure!(
6102 intent_nonce == Some(nonce),
6103 "Verified action nonce does not match the active intent"
6104 );
6105 let attempt = sqlx::query_scalar::<_, i64>(
6106 "
6107 SELECT COUNT(*)
6108 FROM execution_verification_decision
6109 WHERE intent_id = $1 AND decision_class = $2
6110 ",
6111 )
6112 .bind(batch.intent_id)
6113 .bind(batch.decision_class)
6114 .fetch_one(&mut *transaction)
6115 .await
6116 .map_err(|e| anyhow::anyhow!("Failed to number verified action evidence: {e}"))?;
6117
6118 for (index, decision) in batch.decisions.iter().enumerate() {
6119 let height_start = decision
6120 .height_start
6121 .map(i64::try_from)
6122 .transpose()
6123 .context("Verification height exceeds PostgreSQL BIGINT")?;
6124 let height_end = decision
6125 .height_end
6126 .map(i64::try_from)
6127 .transpose()
6128 .context("Verification height exceeds PostgreSQL BIGINT")?;
6129 let transition_key = format!(
6130 "{}:{}:{attempt}:{}:{index}",
6131 batch.decision_class, batch.intent_id, decision.read_class
6132 );
6133 sqlx::query(
6134 "
6135 INSERT INTO execution_verification_decision (
6136 intent_id, nonce, decision_class, read_class, height_start, height_end,
6137 manifest_version, manifest_digest, provider_ids, operator_ids,
6138 failure_domain_ids, response_class, normalized_value_digest,
6139 nonce_revision, outcome, transition_key
6140 )
6141 VALUES (
6142 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
6143 'all_valid', $12, $13, 'verified', $14
6144 )
6145 ",
6146 )
6147 .bind(batch.intent_id)
6148 .bind(nonce)
6149 .bind(batch.decision_class)
6150 .bind(decision.read_class)
6151 .bind(height_start)
6152 .bind(height_end)
6153 .bind(batch.manifest_version)
6154 .bind(batch.manifest_digest)
6155 .bind(batch.provider_ids)
6156 .bind(batch.operator_ids)
6157 .bind(batch.failure_domain_ids)
6158 .bind(&decision.normalized_value_digest)
6159 .bind(revision)
6160 .bind(transition_key)
6161 .execute(&mut *transaction)
6162 .await
6163 .map_err(|e| anyhow::anyhow!("Failed to persist verified action evidence: {e}"))?;
6164 }
6165 transaction
6166 .commit()
6167 .await
6168 .map_err(|e| anyhow::anyhow!("Failed to commit verified action evidence: {e}"))?;
6169 Ok(())
6170 }
6171
6172 pub(crate) async fn load_execution_replacement_cursor(
6173 &self,
6174 intent_id: i64,
6175 chain_id: u32,
6176 wallet_address: &str,
6177 nonce: u64,
6178 manifest_digest: &str,
6179 ) -> anyhow::Result<Option<ExecutionVerifiedHeader>> {
6180 let chain_id = i32::try_from(chain_id)
6181 .context("Replacement scan chain ID exceeds PostgreSQL INTEGER")?;
6182 let nonce =
6183 i64::try_from(nonce).context("Replacement scan nonce exceeds PostgreSQL BIGINT")?;
6184 let row = sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
6185 "
6186 SELECT
6187 h.number, h.hash, h.parent_hash, h.timestamp, h.base_fee_per_gas,
6188 s.manifest_digest
6189 FROM execution_replacement_scan AS s
6190 JOIN execution_verified_finalized_header AS h
6191 ON h.chain_id = s.chain_id
6192 AND h.wallet_address = s.wallet_address
6193 AND h.number = s.finalized_cursor_number
6194 AND h.hash = s.finalized_cursor_hash
6195 WHERE s.intent_id = $1
6196 AND s.chain_id = $2
6197 AND s.wallet_address = $3
6198 AND s.nonce = $4
6199 ",
6200 )
6201 .bind(intent_id)
6202 .bind(chain_id)
6203 .bind(wallet_address)
6204 .bind(nonce)
6205 .fetch_optional(&self.pool)
6206 .await
6207 .context("failed to load replacement scan cursor")?;
6208 row.map(
6209 |(number, hash, parent_hash, timestamp, base_fee, stored_digest)| {
6210 anyhow::ensure!(
6211 stored_digest == manifest_digest,
6212 "Replacement scan manifest identity changed"
6213 );
6214 Ok(ExecutionVerifiedHeader {
6215 number: u64::try_from(number)
6216 .context("Replacement scan cursor number is negative")?,
6217 hash,
6218 parent_hash,
6219 timestamp: u64::try_from(timestamp)
6220 .context("Replacement scan cursor timestamp is negative")?,
6221 base_fee_per_gas: base_fee
6222 .map(|value| {
6223 value.parse::<u128>().map_err(|_| {
6224 anyhow::anyhow!("Replacement scan cursor base fee is invalid")
6225 })
6226 })
6227 .transpose()?,
6228 })
6229 },
6230 )
6231 .transpose()
6232 }
6233
6234 pub(crate) async fn record_execution_replacement_scan(
6235 &self,
6236 scan: &ExecutionReplacementScan<'_>,
6237 ) -> anyhow::Result<()> {
6238 anyhow::ensure!(
6239 !scan.decisions.is_empty()
6240 && scan.provider_ids.len() == 3
6241 && scan.operator_ids.len() == 3
6242 && scan.failure_domain_ids.len() >= 3,
6243 "Replacement scan verification evidence is incomplete"
6244 );
6245 let chain_id = i32::try_from(scan.chain_id)
6246 .context("Replacement scan chain ID exceeds PostgreSQL INTEGER")?;
6247 let nonce = i64::try_from(scan.nonce)
6248 .context("Replacement scan nonce exceeds PostgreSQL BIGINT")?;
6249 let mut transaction = self
6250 .pool
6251 .begin()
6252 .await
6253 .context("failed to start verified replacement scan transition")?;
6254 let (stored_version, stored_digest, stored_nonce, revision) =
6255 sqlx::query_as::<_, (String, String, i64, i64)>(
6256 "
6257 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
6258 FROM execution_verification_nonce
6259 WHERE chain_id = $1 AND wallet_address = $2
6260 FOR SHARE
6261 ",
6262 )
6263 .bind(chain_id)
6264 .bind(scan.wallet_address)
6265 .fetch_optional(&mut *transaction)
6266 .await
6267 .context("failed to read replacement scan nonce ledger")?
6268 .ok_or_else(|| anyhow::anyhow!("Canonical nonce ledger is not initialized"))?;
6269 anyhow::ensure!(
6270 stored_version == scan.manifest_version
6271 && stored_digest == scan.manifest_digest
6272 && stored_nonce == nonce,
6273 "Replacement scan conflicts with the canonical nonce or manifest ledger"
6274 );
6275 let current_status = sqlx::query_scalar::<_, String>(
6276 "
6277 SELECT status
6278 FROM execution_intent
6279 WHERE id = $1 AND chain_id = $2 AND wallet_address = $3
6280 AND nonce = $4 AND active
6281 FOR UPDATE
6282 ",
6283 )
6284 .bind(scan.intent_id)
6285 .bind(chain_id)
6286 .bind(scan.wallet_address)
6287 .bind(nonce)
6288 .fetch_optional(&mut *transaction)
6289 .await
6290 .context("failed to lock the replacement scan intent")?
6291 .ok_or_else(|| anyhow::anyhow!("Active replacement scan intent was not found"))?;
6292
6293 if let Some(cursor) = scan.finalized_cursor {
6294 let number = i64::try_from(cursor.number)
6295 .context("Replacement scan cursor exceeds PostgreSQL BIGINT")?;
6296 let durable_hash = sqlx::query_scalar::<_, String>(
6297 "
6298 SELECT hash
6299 FROM execution_verified_finalized_header
6300 WHERE chain_id = $1 AND wallet_address = $2 AND number = $3
6301 ",
6302 )
6303 .bind(chain_id)
6304 .bind(scan.wallet_address)
6305 .bind(number)
6306 .fetch_optional(&mut *transaction)
6307 .await
6308 .context("failed to validate replacement cursor against finalized headers")?
6309 .ok_or_else(|| {
6310 anyhow::anyhow!(
6311 "Replacement scan cursor {} is not durably finalized",
6312 cursor.number
6313 )
6314 })?;
6315 anyhow::ensure!(
6316 durable_hash == cursor.hash,
6317 "Replacement scan cursor conflicts with the finalized header ledger"
6318 );
6319 let existing = sqlx::query_as::<_, (i64, String)>(
6320 "
6321 SELECT finalized_cursor_number, finalized_cursor_hash
6322 FROM execution_replacement_scan
6323 WHERE intent_id = $1
6324 FOR UPDATE
6325 ",
6326 )
6327 .bind(scan.intent_id)
6328 .fetch_optional(&mut *transaction)
6329 .await
6330 .context("failed to lock replacement scan progress")?;
6331
6332 if let Some((stored_number, stored_hash)) = existing {
6333 anyhow::ensure!(
6334 number > stored_number
6335 || (number == stored_number && cursor.hash == stored_hash),
6336 "Replacement scan cursor regressed or changed"
6337 );
6338 }
6339 sqlx::query(
6340 "
6341 INSERT INTO execution_replacement_scan (
6342 intent_id, chain_id, wallet_address, nonce,
6343 finalized_cursor_number, finalized_cursor_hash, manifest_digest
6344 ) VALUES ($1, $2, $3, $4, $5, $6, $7)
6345 ON CONFLICT (intent_id) DO UPDATE SET
6346 finalized_cursor_number = EXCLUDED.finalized_cursor_number,
6347 finalized_cursor_hash = EXCLUDED.finalized_cursor_hash,
6348 updated_at = NOW()
6349 ",
6350 )
6351 .bind(scan.intent_id)
6352 .bind(chain_id)
6353 .bind(scan.wallet_address)
6354 .bind(nonce)
6355 .bind(number)
6356 .bind(&cursor.hash)
6357 .bind(scan.manifest_digest)
6358 .execute(&mut *transaction)
6359 .await
6360 .context("failed to persist replacement scan cursor")?;
6361 }
6362
6363 let attempt = sqlx::query_scalar::<_, i64>(
6364 "
6365 SELECT COUNT(*)
6366 FROM execution_verification_decision
6367 WHERE intent_id = $1 AND decision_class = 'replacement_scan'
6368 ",
6369 )
6370 .bind(scan.intent_id)
6371 .fetch_one(&mut *transaction)
6372 .await
6373 .context("failed to number replacement scan evidence")?;
6374 for (index, decision) in scan.decisions.iter().enumerate() {
6375 let height_start = decision
6376 .height_start
6377 .map(i64::try_from)
6378 .transpose()
6379 .context("Replacement scan height exceeds PostgreSQL BIGINT")?;
6380 let height_end = decision
6381 .height_end
6382 .map(i64::try_from)
6383 .transpose()
6384 .context("Replacement scan height exceeds PostgreSQL BIGINT")?;
6385 let transition_key = format!(
6386 "replacement_scan:{}:{attempt}:{}:{index}",
6387 scan.intent_id, decision.read_class
6388 );
6389 sqlx::query(
6390 "
6391 INSERT INTO execution_verification_decision (
6392 intent_id, nonce, decision_class, read_class, height_start, height_end,
6393 manifest_version, manifest_digest, provider_ids, operator_ids,
6394 failure_domain_ids, response_class, normalized_value_digest,
6395 nonce_revision, outcome, transition_key
6396 ) VALUES (
6397 $1, $2, 'replacement_scan', $3, $4, $5, $6, $7, $8, $9, $10,
6398 'all_valid', $11, $12, 'verified', $13
6399 )
6400 ",
6401 )
6402 .bind(scan.intent_id)
6403 .bind(nonce)
6404 .bind(decision.read_class)
6405 .bind(height_start)
6406 .bind(height_end)
6407 .bind(scan.manifest_version)
6408 .bind(scan.manifest_digest)
6409 .bind(scan.provider_ids)
6410 .bind(scan.operator_ids)
6411 .bind(scan.failure_domain_ids)
6412 .bind(&decision.normalized_value_digest)
6413 .bind(revision)
6414 .bind(transition_key)
6415 .execute(&mut *transaction)
6416 .await
6417 .context("failed to persist replacement scan evidence")?;
6418 }
6419
6420 if let Some(transaction_hash) = scan.matched_transaction_hash {
6421 anyhow::ensure!(
6422 execution_transition_allowed(¤t_status, TransactionStatus::Replaced),
6423 "Intent cannot attach a verified replacement from status {current_status}"
6424 );
6425 let (hash_id, payload_expected, already_current) =
6426 sqlx::query_as::<_, (i64, bool, bool)>(
6427 "
6428 SELECT id, payload_expected, current
6429 FROM execution_transaction_hash
6430 WHERE intent_id = $1 AND chain_id = $2 AND transaction_hash = $3
6431 FOR UPDATE
6432 ",
6433 )
6434 .bind(scan.intent_id)
6435 .bind(chain_id)
6436 .bind(transaction_hash)
6437 .fetch_optional(&mut *transaction)
6438 .await
6439 .context("failed to lock authenticated replacement payload")?
6440 .ok_or_else(|| {
6441 anyhow::anyhow!(
6442 "Replacement transaction {transaction_hash} has no retained payload"
6443 )
6444 })?;
6445 anyhow::ensure!(
6446 payload_expected,
6447 "Replacement transaction {transaction_hash} is not authenticated"
6448 );
6449
6450 if !already_current {
6451 sqlx::query(
6452 "
6453 UPDATE execution_transaction_hash
6454 SET current = FALSE, status = 'replaced', updated_at = NOW()
6455 WHERE intent_id = $1 AND current
6456 ",
6457 )
6458 .bind(scan.intent_id)
6459 .execute(&mut *transaction)
6460 .await
6461 .context("failed to retire the prior transaction hash")?;
6462 sqlx::query(
6463 "
6464 UPDATE execution_transaction_hash
6465 SET current = TRUE, status = 'replaced', updated_at = NOW()
6466 WHERE id = $1
6467 ",
6468 )
6469 .bind(hash_id)
6470 .execute(&mut *transaction)
6471 .await
6472 .context("failed to attach the authenticated replacement hash")?;
6473 sqlx::query(
6474 "UPDATE execution_intent SET status = 'replaced', updated_at = NOW() WHERE id = $1",
6475 )
6476 .bind(scan.intent_id)
6477 .execute(&mut *transaction)
6478 .await
6479 .context("failed to mark the execution intent replaced")?;
6480 sqlx::query(
6481 "
6482 INSERT INTO execution_transaction_transition (
6483 intent_id, transaction_hash_id, transition_key, from_status, to_status
6484 ) VALUES ($1, $2, $3, $4, 'replaced')
6485 ON CONFLICT (intent_id, transition_key) DO NOTHING
6486 ",
6487 )
6488 .bind(scan.intent_id)
6489 .bind(hash_id)
6490 .bind(format!("replacement_verified:{transaction_hash}"))
6491 .bind(current_status)
6492 .execute(&mut *transaction)
6493 .await
6494 .context("failed to record the verified replacement transition")?;
6495 }
6496 }
6497
6498 transaction
6499 .commit()
6500 .await
6501 .context("failed to commit verified replacement scan")?;
6502 Ok(())
6503 }
6504
6505 pub async fn mark_execution_intent_recoverable(&self, intent_id: i64) -> anyhow::Result<()> {
6511 let mut transaction = self.pool.begin().await.map_err(|e| {
6512 anyhow::anyhow!("Failed to start recoverable execution transition: {e}")
6513 })?;
6514 let current_status = sqlx::query_scalar::<_, String>(
6515 "SELECT status FROM execution_intent WHERE id = $1 FOR UPDATE",
6516 )
6517 .bind(intent_id)
6518 .fetch_optional(&mut *transaction)
6519 .await
6520 .map_err(|e| anyhow::anyhow!("Failed to lock recoverable execution intent: {e}"))?
6521 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))?;
6522 anyhow::ensure!(
6523 current_status == "prepared",
6524 "Execution intent {intent_id} is {current_status}, not recoverable before signing"
6525 );
6526 let result = sqlx::query(
6527 "
6528 UPDATE execution_intent
6529 SET status = 'recoverable', active = FALSE, updated_at = NOW()
6530 WHERE id = $1 AND status = 'prepared'
6531 ",
6532 )
6533 .bind(intent_id)
6534 .execute(&mut *transaction)
6535 .await
6536 .map_err(|e| anyhow::anyhow!("Failed to mark execution intent recoverable: {e}"))?;
6537 anyhow::ensure!(
6538 result.rows_affected() == 1,
6539 "Execution intent {intent_id} is not recoverable from preparation"
6540 );
6541 sqlx::query(
6542 "
6543 INSERT INTO execution_transaction_transition (
6544 intent_id, transition_key, from_status, to_status
6545 ) VALUES ($1, 'recoverable', $2, 'recoverable')
6546 ON CONFLICT (intent_id, transition_key) DO NOTHING
6547 ",
6548 )
6549 .bind(intent_id)
6550 .bind(current_status)
6551 .execute(&mut *transaction)
6552 .await
6553 .map_err(|e| anyhow::anyhow!("Failed to record recoverable transition: {e}"))?;
6554 transaction
6555 .commit()
6556 .await
6557 .map_err(|e| anyhow::anyhow!("Failed to commit recoverable transition: {e}"))?;
6558 Ok(())
6559 }
6560
6561 pub async fn add_execution_transaction_hash(
6568 &self,
6569 intent_id: i64,
6570 chain_id: u32,
6571 transaction_hash: &str,
6572 raw_transaction: &[u8],
6573 ) -> anyhow::Result<ExecutionTransactionHashRow> {
6574 self.add_execution_transaction_payload(
6575 intent_id,
6576 chain_id,
6577 transaction_hash,
6578 Some(raw_transaction),
6579 None,
6580 )
6581 .await
6582 }
6583
6584 pub(crate) async fn add_execution_transaction_envelope(
6585 &self,
6586 intent_id: i64,
6587 chain_id: u32,
6588 transaction_hash: &str,
6589 sealed_transaction: &[u8],
6590 ) -> anyhow::Result<ExecutionTransactionHashRow> {
6591 self.add_execution_transaction_payload(
6592 intent_id,
6593 chain_id,
6594 transaction_hash,
6595 None,
6596 Some(sealed_transaction),
6597 )
6598 .await
6599 }
6600
6601 async fn add_execution_transaction_payload(
6602 &self,
6603 intent_id: i64,
6604 chain_id: u32,
6605 transaction_hash: &str,
6606 raw_transaction: Option<&[u8]>,
6607 sealed_transaction: Option<&[u8]>,
6608 ) -> anyhow::Result<ExecutionTransactionHashRow> {
6609 anyhow::ensure!(
6610 raw_transaction.is_some() != sealed_transaction.is_some(),
6611 "A signed transaction requires exactly one payload representation"
6612 );
6613 let chain_id_db = i32::try_from(chain_id)
6614 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
6615 let mut transaction =
6616 self.pool.begin().await.map_err(|e| {
6617 anyhow::anyhow!("Failed to start signed transaction persistence: {e}")
6618 })?;
6619
6620 if let Some(envelope) = sealed_transaction {
6621 let state_row = sqlx::query(
6622 "SELECT deployment_id, protocol_version, operation, active_key_id \
6623 FROM execution_payload_state WHERE component = 'signed_transactions' FOR SHARE",
6624 )
6625 .fetch_optional(&mut *transaction)
6626 .await
6627 .context("failed to lock execution payload state for protected persistence")?
6628 .ok_or_else(|| anyhow::anyhow!("Execution payload protection is not active"))?;
6629 let state = execution_payload_state_from_row(&state_row)?;
6630 anyhow::ensure!(
6631 state.protocol_version == EXECUTION_PAYLOAD_PROTOCOL_VERSION
6632 && state.operation == "ready",
6633 "Execution payload storage is not ready for protected persistence"
6634 );
6635 anyhow::ensure!(
6636 envelope_key_id(envelope)?.as_slice() == state.active_key_id.as_slice(),
6637 "Signed transaction envelope does not use the database active key"
6638 );
6639 } else {
6640 let marker = sqlx::query_scalar::<_, bool>(
6641 "SELECT EXISTS (SELECT 1 FROM execution_schema_version WHERE component = $1)",
6642 )
6643 .bind(EXECUTION_PAYLOAD_COMPONENT)
6644 .fetch_one(&mut *transaction)
6645 .await
6646 .context("failed to inspect execution payload marker")?;
6647 anyhow::ensure!(
6648 !marker,
6649 "Plaintext signed transaction persistence is disabled after payload activation"
6650 );
6651 }
6652 let current_status = sqlx::query_scalar::<_, String>(
6653 "SELECT status FROM execution_intent WHERE id = $1 FOR UPDATE",
6654 )
6655 .bind(intent_id)
6656 .fetch_optional(&mut *transaction)
6657 .await
6658 .map_err(|e| anyhow::anyhow!("Failed to lock execution intent {intent_id}: {e}"))?
6659 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))?;
6660 anyhow::ensure!(
6661 current_status == TransactionStatus::Prepared.as_str()
6662 || current_status == TransactionStatus::Signed.as_str(),
6663 "Execution intent {intent_id} is {current_status}, not prepared for signing"
6664 );
6665
6666 let row = sqlx::query_as::<_, ExecutionTransactionHashRow>(
6667 "
6668 INSERT INTO execution_transaction_hash (
6669 intent_id, chain_id, transaction_hash, payload_expected,
6670 raw_transaction, sealed_transaction, status
6671 )
6672 SELECT id, chain_id, $3, TRUE, $4, $5, 'signed'
6673 FROM execution_intent
6674 WHERE id = $1 AND chain_id = $2 AND nonce IS NOT NULL
6675 ON CONFLICT (chain_id, transaction_hash) DO UPDATE
6676 SET transaction_hash = EXCLUDED.transaction_hash
6677 WHERE execution_transaction_hash.intent_id = EXCLUDED.intent_id
6678 AND execution_transaction_hash.payload_expected
6679 AND (
6680 (
6681 EXCLUDED.raw_transaction IS NOT NULL
6682 AND execution_transaction_hash.raw_transaction = EXCLUDED.raw_transaction
6683 AND execution_transaction_hash.sealed_transaction IS NULL
6684 )
6685 OR (
6686 EXCLUDED.sealed_transaction IS NOT NULL
6687 AND execution_transaction_hash.raw_transaction IS NULL
6688 AND execution_transaction_hash.sealed_transaction IS NOT NULL
6689 )
6690 )
6691 RETURNING
6692 id, intent_id, chain_id, transaction_hash, payload_expected,
6693 raw_transaction, sealed_transaction, status,
6694 block_number, block_hash, receipt_success, gas_used,
6695 effective_gas_price, current
6696 ",
6697 )
6698 .bind(intent_id)
6699 .bind(chain_id_db)
6700 .bind(transaction_hash)
6701 .bind(raw_transaction)
6702 .bind(sealed_transaction)
6703 .fetch_optional(&mut *transaction)
6704 .await
6705 .map_err(|e| {
6706 anyhow::anyhow!("Failed to persist signed transaction {transaction_hash}: {e}")
6707 })?
6708 .ok_or_else(|| {
6709 anyhow::anyhow!(
6710 "Signed transaction {transaction_hash} conflicts with its persisted identity"
6711 )
6712 })?;
6713
6714 sqlx::query(
6715 "UPDATE execution_intent SET status = 'signed', updated_at = NOW() WHERE id = $1",
6716 )
6717 .bind(intent_id)
6718 .execute(&mut *transaction)
6719 .await
6720 .map_err(|e| anyhow::anyhow!("Failed to mark execution intent signed: {e}"))?;
6721 sqlx::query(
6722 "
6723 INSERT INTO execution_transaction_transition (
6724 intent_id, transaction_hash_id, transition_key, from_status, to_status
6725 ) VALUES ($1, $2, $3, $4, 'signed')
6726 ON CONFLICT (intent_id, transition_key) DO NOTHING
6727 ",
6728 )
6729 .bind(intent_id)
6730 .bind(row.id)
6731 .bind(format!("signed:{transaction_hash}"))
6732 .bind(current_status)
6733 .execute(&mut *transaction)
6734 .await
6735 .map_err(|e| anyhow::anyhow!("Failed to record signed transaction transition: {e}"))?;
6736
6737 transaction
6738 .commit()
6739 .await
6740 .map_err(|e| anyhow::anyhow!("Failed to commit signed transaction: {e}"))?;
6741 Ok(row)
6742 }
6743
6744 #[expect(
6750 clippy::too_many_arguments,
6751 reason = "the parameters are the canonical receipt observation persisted atomically"
6752 )]
6753 pub async fn record_execution_status(
6754 &self,
6755 intent_id: i64,
6756 transaction_hash: &str,
6757 status: TransactionStatus,
6758 block_number: Option<u64>,
6759 block_hash: Option<&str>,
6760 receipt_success: Option<bool>,
6761 gas_used: Option<u64>,
6762 effective_gas_price: Option<&str>,
6763 ) -> anyhow::Result<()> {
6764 let block_number_db = block_number
6765 .map(i64::try_from)
6766 .transpose()
6767 .with_context(|| {
6768 format!(
6769 "Execution block number {} exceeds PostgreSQL BIGINT",
6770 block_number.unwrap_or_default()
6771 )
6772 })?;
6773 let gas_used_db = gas_used.map(i64::try_from).transpose().with_context(|| {
6774 format!(
6775 "Execution gas used {} exceeds PostgreSQL BIGINT",
6776 gas_used.unwrap_or_default()
6777 )
6778 })?;
6779 let mut transaction = self
6780 .pool
6781 .begin()
6782 .await
6783 .map_err(|e| anyhow::anyhow!("Failed to start execution status transition: {e}"))?;
6784 let (current_status, fill_emitted, terminal_emitted) =
6785 sqlx::query_as::<_, (String, bool, bool)>(
6786 "SELECT status, fill_emitted, terminal_emitted FROM execution_intent WHERE id = $1 FOR UPDATE",
6787 )
6788 .bind(intent_id)
6789 .fetch_optional(&mut *transaction)
6790 .await
6791 .map_err(|e| anyhow::anyhow!("Failed to lock execution intent {intent_id}: {e}"))?
6792 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))?;
6793 anyhow::ensure!(
6794 execution_transition_allowed(¤t_status, status),
6795 "Invalid execution transition for intent {intent_id}: {current_status} -> {}",
6796 status.as_str()
6797 );
6798
6799 let active = match status {
6800 TransactionStatus::Finalized | TransactionStatus::Reverted => {
6801 !fill_emitted && !terminal_emitted
6802 }
6803 TransactionStatus::Recoverable => false,
6804 _ => true,
6805 };
6806 let hash_result = sqlx::query(
6807 "
6808 UPDATE execution_transaction_hash
6809 SET status = $3,
6810 block_number = COALESCE($4, block_number),
6811 block_hash = COALESCE($5, block_hash),
6812 receipt_success = COALESCE($6, receipt_success),
6813 gas_used = COALESCE($7, gas_used),
6814 effective_gas_price = COALESCE($8, effective_gas_price),
6815 updated_at = NOW()
6816 WHERE intent_id = $1 AND transaction_hash = $2
6817 ",
6818 )
6819 .bind(intent_id)
6820 .bind(transaction_hash)
6821 .bind(status.as_str())
6822 .bind(block_number_db)
6823 .bind(block_hash)
6824 .bind(receipt_success)
6825 .bind(gas_used_db)
6826 .bind(effective_gas_price)
6827 .execute(&mut *transaction)
6828 .await
6829 .map_err(|e| anyhow::anyhow!("Failed to update execution hash {transaction_hash}: {e}"))?;
6830 anyhow::ensure!(
6831 hash_result.rows_affected() == 1,
6832 "Execution transaction hash {transaction_hash} was not found for intent {intent_id}"
6833 );
6834
6835 sqlx::query(
6836 "
6837 UPDATE execution_intent
6838 SET status = $2, active = $3, updated_at = NOW()
6839 WHERE id = $1
6840 ",
6841 )
6842 .bind(intent_id)
6843 .bind(status.as_str())
6844 .bind(active)
6845 .execute(&mut *transaction)
6846 .await
6847 .map_err(|e| anyhow::anyhow!("Failed to update execution intent {intent_id}: {e}"))?;
6848
6849 let transition_key = format!(
6850 "{}:{transaction_hash}:{}:{}",
6851 status.as_str(),
6852 block_number.map_or_else(|| "none".to_string(), |value| value.to_string()),
6853 block_hash.unwrap_or("none")
6854 );
6855 sqlx::query(
6856 "
6857 INSERT INTO execution_transaction_transition (
6858 intent_id, transaction_hash_id, transition_key, from_status, to_status,
6859 block_number, block_hash
6860 )
6861 SELECT $1, id, $3, $4, $5, $6, $7
6862 FROM execution_transaction_hash
6863 WHERE intent_id = $1 AND transaction_hash = $2
6864 ON CONFLICT (intent_id, transition_key) DO NOTHING
6865 ",
6866 )
6867 .bind(intent_id)
6868 .bind(transaction_hash)
6869 .bind(transition_key)
6870 .bind(current_status)
6871 .bind(status.as_str())
6872 .bind(block_number_db)
6873 .bind(block_hash)
6874 .execute(&mut *transaction)
6875 .await
6876 .map_err(|e| anyhow::anyhow!("Failed to record execution transition: {e}"))?;
6877
6878 transaction
6879 .commit()
6880 .await
6881 .map_err(|e| anyhow::anyhow!("Failed to commit execution transition: {e}"))?;
6882 Ok(())
6883 }
6884
6885 pub(crate) async fn record_execution_finality_verified(
6892 &self,
6893 finality: &ExecutionFinalityTransition<'_>,
6894 ) -> anyhow::Result<()> {
6895 anyhow::ensure!(
6896 matches!(
6897 finality.status,
6898 TransactionStatus::Finalized | TransactionStatus::Reverted
6899 ),
6900 "Verified finality requires a finalized or reverted status"
6901 );
6902 anyhow::ensure!(
6903 !finality.decisions.is_empty(),
6904 "Verified finality requires decision evidence"
6905 );
6906 anyhow::ensure!(
6907 !finality.finalized_headers.is_empty()
6908 && finality.finalized_headers.windows(2).all(|headers| {
6909 headers[1].number == headers[0].number.saturating_add(1)
6910 && headers[1].parent_hash == headers[0].hash
6911 })
6912 && finality
6913 .finalized_headers
6914 .last()
6915 .is_some_and(|header| header.number >= finality.block_number),
6916 "Verified finality headers must form a continuous chain through the inclusion height"
6917 );
6918 let chain_id = i32::try_from(finality.chain_id)
6919 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
6920 let nonce =
6921 i64::try_from(finality.nonce).context("Execution nonce exceeds PostgreSQL BIGINT")?;
6922 let next_nonce = nonce
6923 .checked_add(1)
6924 .ok_or_else(|| anyhow::anyhow!("Canonical nonce overflow"))?;
6925 let block_number = i64::try_from(finality.block_number)
6926 .context("Finality block exceeds PostgreSQL BIGINT")?;
6927 let gas_used = i64::try_from(finality.gas_used)
6928 .context("Finality gas used exceeds PostgreSQL BIGINT")?;
6929 let mut transaction =
6930 self.pool.begin().await.map_err(|e| {
6931 anyhow::anyhow!("Failed to start verified finality transition: {e}")
6932 })?;
6933 let (manifest_version, manifest_digest, stored_nonce, revision) =
6934 sqlx::query_as::<_, (String, String, i64, i64)>(
6935 "
6936 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
6937 FROM execution_verification_nonce
6938 WHERE chain_id = $1 AND wallet_address = $2
6939 FOR UPDATE
6940 ",
6941 )
6942 .bind(chain_id)
6943 .bind(finality.wallet_address)
6944 .fetch_optional(&mut *transaction)
6945 .await
6946 .map_err(|e| anyhow::anyhow!("Failed to lock finality nonce ledger: {e}"))?
6947 .ok_or_else(|| anyhow::anyhow!("Canonical nonce ledger is not initialized"))?;
6948 anyhow::ensure!(
6949 manifest_version == finality.manifest_version
6950 && manifest_digest == finality.manifest_digest,
6951 "Verified finality manifest identity changed"
6952 );
6953 anyhow::ensure!(
6954 stored_nonce == nonce,
6955 "Finalized nonce {} does not match canonical nonce {stored_nonce}",
6956 finality.nonce
6957 );
6958 let stored_tip = sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
6959 "
6960 SELECT number, hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
6961 FROM execution_verified_finalized_header
6962 WHERE chain_id = $1 AND wallet_address = $2
6963 ORDER BY number DESC
6964 LIMIT 1
6965 ",
6966 )
6967 .bind(chain_id)
6968 .bind(finality.wallet_address)
6969 .fetch_one(&mut *transaction)
6970 .await
6971 .map_err(|e| anyhow::anyhow!("Failed to lock finalized header tip: {e}"))?;
6972 let first_header = &finality.finalized_headers[0];
6973 anyhow::ensure!(
6974 stored_tip
6975 == (
6976 i64::try_from(first_header.number)
6977 .context("Verified finalized height exceeds PostgreSQL BIGINT")?,
6978 first_header.hash.clone(),
6979 first_header.parent_hash.clone(),
6980 i64::try_from(first_header.timestamp)
6981 .context("Verified finalized timestamp exceeds PostgreSQL BIGINT")?,
6982 first_header.base_fee_per_gas.map(|value| value.to_string()),
6983 finality.manifest_digest.to_string(),
6984 ),
6985 "Verified finality extension does not start at the durable tip"
6986 );
6987
6988 for header in finality.finalized_headers.iter().skip(1) {
6989 let number = i64::try_from(header.number)
6990 .context("Verified finalized height exceeds PostgreSQL BIGINT")?;
6991 let timestamp = i64::try_from(header.timestamp)
6992 .context("Verified finalized timestamp exceeds PostgreSQL BIGINT")?;
6993 let base_fee = header.base_fee_per_gas.map(|value| value.to_string());
6994 sqlx::query(
6995 "
6996 INSERT INTO execution_verified_finalized_header (
6997 chain_id, wallet_address, number, hash, parent_hash, timestamp,
6998 base_fee_per_gas, manifest_digest
6999 )
7000 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
7001 ON CONFLICT (chain_id, wallet_address, number) DO NOTHING
7002 ",
7003 )
7004 .bind(chain_id)
7005 .bind(finality.wallet_address)
7006 .bind(number)
7007 .bind(&header.hash)
7008 .bind(&header.parent_hash)
7009 .bind(timestamp)
7010 .bind(&base_fee)
7011 .bind(finality.manifest_digest)
7012 .execute(&mut *transaction)
7013 .await
7014 .map_err(|e| anyhow::anyhow!("Failed to extend finalized header ledger: {e}"))?;
7015 let stored = sqlx::query_as::<_, (String, String, i64, Option<String>, String)>(
7016 "
7017 SELECT hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
7018 FROM execution_verified_finalized_header
7019 WHERE chain_id = $1 AND wallet_address = $2 AND number = $3
7020 ",
7021 )
7022 .bind(chain_id)
7023 .bind(finality.wallet_address)
7024 .bind(number)
7025 .fetch_one(&mut *transaction)
7026 .await
7027 .map_err(|e| anyhow::anyhow!("Failed to validate finalized header ledger: {e}"))?;
7028 anyhow::ensure!(
7029 stored
7030 == (
7031 header.hash.clone(),
7032 header.parent_hash.clone(),
7033 timestamp,
7034 base_fee,
7035 finality.manifest_digest.to_string(),
7036 ),
7037 "Finalized header ledger conflicts at height {}",
7038 header.number
7039 );
7040 }
7041
7042 let (current_status, intent_nonce, fill_emitted, terminal_emitted) =
7043 sqlx::query_as::<_, (String, Option<i64>, bool, bool)>(
7044 "
7045 SELECT status, nonce, fill_emitted, terminal_emitted
7046 FROM execution_intent
7047 WHERE id = $1 AND chain_id = $2 AND wallet_address = $3 AND active
7048 FOR UPDATE
7049 ",
7050 )
7051 .bind(finality.intent_id)
7052 .bind(chain_id)
7053 .bind(finality.wallet_address)
7054 .fetch_optional(&mut *transaction)
7055 .await
7056 .map_err(|e| anyhow::anyhow!("Failed to lock intent for verified finality: {e}"))?
7057 .ok_or_else(|| anyhow::anyhow!("Active finality intent was not found"))?;
7058 anyhow::ensure!(
7059 intent_nonce == Some(nonce)
7060 && execution_transition_allowed(¤t_status, finality.status),
7061 "Intent cannot make the verified finality transition"
7062 );
7063
7064 for (index, decision) in finality.decisions.iter().enumerate() {
7065 let height_start = decision
7066 .height_start
7067 .map(i64::try_from)
7068 .transpose()
7069 .context("Verification height exceeds PostgreSQL BIGINT")?;
7070 let height_end = decision
7071 .height_end
7072 .map(i64::try_from)
7073 .transpose()
7074 .context("Verification height exceeds PostgreSQL BIGINT")?;
7075 let transition_key = format!(
7076 "finality:{}:{}:{index}",
7077 finality.intent_id, decision.read_class
7078 );
7079 sqlx::query(
7080 "
7081 INSERT INTO execution_verification_decision (
7082 intent_id, nonce, decision_class, read_class, height_start, height_end,
7083 manifest_version, manifest_digest, provider_ids, operator_ids,
7084 failure_domain_ids, response_class, normalized_value_digest,
7085 nonce_revision, outcome, transition_key
7086 )
7087 VALUES (
7088 $1, $2, 'finality', $3, $4, $5, $6, $7, $8, $9, $10,
7089 'all_valid', $11, $12, 'verified', $13
7090 )
7091 ",
7092 )
7093 .bind(finality.intent_id)
7094 .bind(nonce)
7095 .bind(decision.read_class)
7096 .bind(height_start)
7097 .bind(height_end)
7098 .bind(finality.manifest_version)
7099 .bind(finality.manifest_digest)
7100 .bind(finality.provider_ids)
7101 .bind(finality.operator_ids)
7102 .bind(finality.failure_domain_ids)
7103 .bind(&decision.normalized_value_digest)
7104 .bind(revision)
7105 .bind(transition_key)
7106 .execute(&mut *transaction)
7107 .await
7108 .map_err(|e| anyhow::anyhow!("Failed to persist finality verification: {e}"))?;
7109 }
7110
7111 let hash_result = sqlx::query(
7112 "
7113 UPDATE execution_transaction_hash
7114 SET status = $3, block_number = $4, block_hash = $5,
7115 receipt_success = $6, gas_used = $7, effective_gas_price = $8,
7116 updated_at = NOW()
7117 WHERE intent_id = $1 AND transaction_hash = $2
7118 ",
7119 )
7120 .bind(finality.intent_id)
7121 .bind(finality.transaction_hash)
7122 .bind(finality.status.as_str())
7123 .bind(block_number)
7124 .bind(finality.block_hash)
7125 .bind(finality.receipt_success)
7126 .bind(gas_used)
7127 .bind(finality.effective_gas_price)
7128 .execute(&mut *transaction)
7129 .await
7130 .map_err(|e| anyhow::anyhow!("Failed to record verified finality receipt: {e}"))?;
7131 anyhow::ensure!(
7132 hash_result.rows_affected() == 1,
7133 "Finality transaction hash was not found"
7134 );
7135
7136 let active = !fill_emitted && !terminal_emitted;
7137 sqlx::query(
7138 "UPDATE execution_intent SET status = $2, active = $3, updated_at = NOW() WHERE id = $1",
7139 )
7140 .bind(finality.intent_id)
7141 .bind(finality.status.as_str())
7142 .bind(active)
7143 .execute(&mut *transaction)
7144 .await
7145 .map_err(|e| anyhow::anyhow!("Failed to record verified finality intent: {e}"))?;
7146
7147 let transition_key = format!(
7148 "{}:{}:{}:{}",
7149 finality.status.as_str(),
7150 finality.transaction_hash,
7151 finality.block_number,
7152 finality.block_hash
7153 );
7154 sqlx::query(
7155 "
7156 INSERT INTO execution_transaction_transition (
7157 intent_id, transaction_hash_id, transition_key, from_status, to_status,
7158 block_number, block_hash
7159 )
7160 SELECT $1, id, $3, $4, $5, $6, $7
7161 FROM execution_transaction_hash
7162 WHERE intent_id = $1 AND transaction_hash = $2
7163 ",
7164 )
7165 .bind(finality.intent_id)
7166 .bind(finality.transaction_hash)
7167 .bind(transition_key)
7168 .bind(current_status)
7169 .bind(finality.status.as_str())
7170 .bind(block_number)
7171 .bind(finality.block_hash)
7172 .execute(&mut *transaction)
7173 .await
7174 .map_err(|e| anyhow::anyhow!("Failed to record verified finality transition: {e}"))?;
7175
7176 let nonce_result = sqlx::query(
7177 "
7178 UPDATE execution_verification_nonce
7179 SET next_canonical_nonce = $3, revision = revision + 1, updated_at = NOW()
7180 WHERE chain_id = $1 AND wallet_address = $2
7181 AND next_canonical_nonce = $4 AND revision = $5
7182 ",
7183 )
7184 .bind(chain_id)
7185 .bind(finality.wallet_address)
7186 .bind(next_nonce)
7187 .bind(nonce)
7188 .bind(revision)
7189 .execute(&mut *transaction)
7190 .await
7191 .map_err(|e| anyhow::anyhow!("Failed to advance canonical nonce ledger: {e}"))?;
7192 anyhow::ensure!(
7193 nonce_result.rows_affected() == 1,
7194 "Canonical nonce ledger changed during finality transition"
7195 );
7196 transaction
7197 .commit()
7198 .await
7199 .map_err(|e| anyhow::anyhow!("Failed to commit verified finality transition: {e}"))?;
7200 Ok(())
7201 }
7202
7203 pub(crate) async fn get_execution_intent(
7205 &self,
7206 intent_id: i64,
7207 ) -> anyhow::Result<ExecutionIntentRow> {
7208 sqlx::query_as::<_, ExecutionIntentRow>(
7209 "
7210 SELECT
7211 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
7212 client_order_id, trader_id, strategy_id, account_id, instrument_id,
7213 pool_address, transaction_to, transaction_input, transaction_value,
7214 amount_in, created_block, acknowledgement_emitted, fill_emitted,
7215 terminal_emitted, active
7216 FROM execution_intent
7217 WHERE id = $1
7218 ",
7219 )
7220 .bind(intent_id)
7221 .fetch_optional(&self.pool)
7222 .await
7223 .context("failed to load execution intent")?
7224 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))
7225 }
7226
7227 pub async fn get_active_execution_intent(
7233 &self,
7234 chain_id: u32,
7235 wallet_address: &str,
7236 ) -> anyhow::Result<Option<ExecutionIntentRow>> {
7237 let chain_id_db = i32::try_from(chain_id)
7238 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
7239 let mut transaction = self
7240 .pool
7241 .begin_with("BEGIN ISOLATION LEVEL READ COMMITTED")
7242 .await
7243 .context("failed to start active execution intent reconciliation")?;
7244 Self::lock_execution_signer(&mut transaction, chain_id, wallet_address).await?;
7245 let intent = sqlx::query_as::<_, ExecutionIntentRow>(
7246 "
7247 SELECT
7248 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
7249 client_order_id, trader_id, strategy_id, account_id, instrument_id,
7250 pool_address, transaction_to, transaction_input, transaction_value,
7251 amount_in, created_block, acknowledgement_emitted, fill_emitted,
7252 terminal_emitted, active
7253 FROM execution_intent
7254 WHERE chain_id = $1 AND wallet_address = $2 AND active
7255 ",
7256 )
7257 .bind(chain_id_db)
7258 .bind(wallet_address)
7259 .fetch_optional(&mut *transaction)
7260 .await
7261 .context("failed to load active execution intent")?;
7262 transaction
7263 .commit()
7264 .await
7265 .context("failed to complete active execution intent reconciliation")?;
7266 Ok(intent)
7267 }
7268
7269 async fn lock_execution_signer(
7270 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
7271 chain_id: u32,
7272 wallet_address: &str,
7273 ) -> anyhow::Result<()> {
7274 let lock = PgAdvisoryLock::new(format!(
7275 "nautilus:blockchain:execution:{chain_id}:{}",
7276 wallet_address.to_ascii_lowercase()
7277 ));
7278 let PgAdvisoryLockKey::BigInt(lock_key) = lock.key() else {
7279 unreachable!("string advisory locks use the 64-bit key space");
7280 };
7281
7282 sqlx::query("SELECT pg_advisory_xact_lock($1)")
7285 .bind(*lock_key)
7286 .execute(&mut **transaction)
7287 .await
7288 .context("failed to acquire execution signer reservation fence")?;
7289 Ok(())
7290 }
7291
7292 pub async fn has_recoverable_signed_execution(
7298 &self,
7299 chain_id: u32,
7300 wallet_address: &str,
7301 ) -> anyhow::Result<bool> {
7302 let chain_id_db = i32::try_from(chain_id)
7303 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
7304 sqlx::query_scalar::<_, bool>(
7305 "
7306 SELECT EXISTS (
7307 SELECT 1
7308 FROM execution_intent AS intent
7309 JOIN execution_transaction_hash AS hash ON hash.intent_id = intent.id
7310 WHERE intent.chain_id = $1
7311 AND intent.wallet_address = $2
7312 AND intent.status = 'recoverable'
7313 AND (hash.raw_transaction IS NOT NULL OR hash.sealed_transaction IS NOT NULL)
7314 )
7315 ",
7316 )
7317 .bind(chain_id_db)
7318 .bind(wallet_address)
7319 .fetch_one(&self.pool)
7320 .await
7321 .map_err(|e| anyhow::anyhow!("Failed to inspect recoverable signed executions: {e}"))
7322 }
7323
7324 pub async fn get_execution_transaction_hashes(
7330 &self,
7331 intent_id: i64,
7332 ) -> anyhow::Result<Vec<ExecutionTransactionHashRow>> {
7333 sqlx::query_as::<_, ExecutionTransactionHashRow>(
7334 "
7335 SELECT
7336 id, intent_id, chain_id, transaction_hash, payload_expected,
7337 raw_transaction, sealed_transaction, status,
7338 block_number, block_hash, receipt_success, gas_used,
7339 effective_gas_price, current
7340 FROM execution_transaction_hash
7341 WHERE intent_id = $1
7342 ORDER BY id
7343 ",
7344 )
7345 .bind(intent_id)
7346 .fetch_all(&self.pool)
7347 .await
7348 .map_err(|e| anyhow::anyhow!("Failed to load execution transaction hashes: {e}"))
7349 }
7350
7351 pub async fn mark_execution_event_emitted(
7358 &self,
7359 intent_id: i64,
7360 event: &str,
7361 ) -> anyhow::Result<()> {
7362 let statement = match event {
7363 "acknowledgement" => {
7364 "UPDATE execution_intent SET acknowledgement_emitted = TRUE, updated_at = NOW() WHERE id = $1"
7365 }
7366 "fill" => {
7367 "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"
7368 }
7369 "terminal" => {
7370 "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"
7371 }
7372 _ => anyhow::bail!("Unknown execution event marker {event}"),
7373 };
7374 let result = sqlx::query(statement)
7375 .bind(intent_id)
7376 .execute(&self.pool)
7377 .await
7378 .map_err(|e| anyhow::anyhow!("Failed to mark execution {event} emitted: {e}"))?;
7379 anyhow::ensure!(
7380 result.rows_affected() == 1,
7381 "Execution intent {intent_id} cannot mark {event} emitted"
7382 );
7383 Ok(())
7384 }
7385
7386 pub async fn update_execution_transaction_status(
7392 &self,
7393 chain_id: u32,
7394 transaction_hash: &str,
7395 status: &str,
7396 ) -> anyhow::Result<()> {
7397 let result = sqlx::query(
7398 "
7399 UPDATE execution_transaction
7400 SET status = $3
7401 WHERE chain_id = $1 AND transaction_hash = $2
7402 ",
7403 )
7404 .bind(chain_id as i32)
7405 .bind(transaction_hash)
7406 .bind(status)
7407 .execute(&self.pool)
7408 .await
7409 .map_err(|e| anyhow::anyhow!("Failed to update execution_transaction table: {e}"))?;
7410
7411 anyhow::ensure!(
7412 result.rows_affected() == 1,
7413 "Execution transaction {transaction_hash} was not found for status update"
7414 );
7415 Ok(())
7416 }
7417
7418 pub async fn get_execution_transaction(
7424 &self,
7425 chain_id: u32,
7426 transaction_hash: &str,
7427 ) -> anyhow::Result<Option<ExecutionTransactionRow>> {
7428 let chain_id_db = i32::try_from(chain_id)
7429 .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
7430 sqlx::query_as::<_, ExecutionTransactionRow>(
7431 "
7432 SELECT wallet_address, nonce, transaction_hash, purpose, status, client_order_id
7433 FROM (
7434 SELECT
7435 intent.wallet_address,
7436 intent.nonce,
7437 hash.transaction_hash,
7438 intent.purpose,
7439 intent.status,
7440 intent.client_order_id,
7441 0 AS source_priority
7442 FROM execution_transaction_hash AS hash
7443 JOIN execution_intent AS intent ON intent.id = hash.intent_id
7444 WHERE hash.chain_id = $1 AND hash.transaction_hash = $2
7445 UNION ALL
7446 SELECT
7447 wallet_address,
7448 nonce,
7449 transaction_hash,
7450 purpose,
7451 status,
7452 client_order_id,
7453 1 AS source_priority
7454 FROM execution_transaction
7455 WHERE chain_id = $1 AND transaction_hash = $2
7456 ) AS record
7457 ORDER BY source_priority
7458 LIMIT 1
7459 ",
7460 )
7461 .bind(chain_id_db)
7462 .bind(transaction_hash)
7463 .fetch_optional(&self.pool)
7464 .await
7465 .map_err(|e| anyhow::anyhow!("Failed to load from execution_transaction table: {e}"))
7466 }
7467}
7468
7469fn execution_payload_state_from_row(
7470 row: &sqlx::postgres::PgRow,
7471) -> anyhow::Result<ExecutionPayloadState> {
7472 Ok(ExecutionPayloadState {
7473 deployment_id: row.try_get("deployment_id")?,
7474 protocol_version: row.try_get("protocol_version")?,
7475 operation: row.try_get("operation")?,
7476 active_key_id: row.try_get("active_key_id")?,
7477 })
7478}
7479
7480fn validate_execution_payload_state(
7481 state: &ExecutionPayloadState,
7482 keys: &PayloadKeySet,
7483) -> anyhow::Result<()> {
7484 anyhow::ensure!(
7485 state.protocol_version == EXECUTION_PAYLOAD_PROTOCOL_VERSION,
7486 "Execution payload protocol version {} is not supported",
7487 state.protocol_version
7488 );
7489 anyhow::ensure!(
7490 state.deployment_id == keys.deployment_id(),
7491 "Execution payload deployment ID does not match this database"
7492 );
7493 anyhow::ensure!(
7494 state.active_key_id.as_slice() == keys.active_key_id(),
7495 "Configured active payload key does not match the database active key"
7496 );
7497 Ok(())
7498}
7499
7500async fn lock_execution_payload_operation(
7501 transaction: &mut Transaction<'_, Postgres>,
7502) -> anyhow::Result<()> {
7503 let lock = PgAdvisoryLock::new("nautilus:blockchain:execution-payload");
7504 let PgAdvisoryLockKey::BigInt(lock_key) = lock.key() else {
7505 unreachable!("string advisory locks use the 64-bit key space");
7506 };
7507 sqlx::query("SELECT pg_advisory_xact_lock($1)")
7508 .bind(*lock_key)
7509 .execute(&mut **transaction)
7510 .await
7511 .context("failed to acquire execution payload operation fence")?;
7512 Ok(())
7513}
7514
7515async fn reserve_execution_payload_seal(
7516 transaction: &mut Transaction<'_, Postgres>,
7517 key_id: &[u8; 32],
7518) -> anyhow::Result<()> {
7519 let seals = sqlx::query_scalar::<_, i64>(
7520 "INSERT INTO execution_payload_key_state (key_id, seals) VALUES ($1, 1) \
7521 ON CONFLICT (key_id) DO UPDATE SET seals = execution_payload_key_state.seals + 1 \
7522 WHERE execution_payload_key_state.seals < $2 \
7523 RETURNING seals",
7524 )
7525 .bind(key_id.as_slice())
7526 .bind(EXECUTION_PAYLOAD_MAX_SEALS - 1)
7527 .fetch_optional(&mut **transaction)
7528 .await
7529 .context("failed to reserve execution payload nonce use")?;
7530 anyhow::ensure!(
7531 seals.is_some(),
7532 "Execution payload key reached its seal limit; rotate the active key before continuing"
7533 );
7534 Ok(())
7535}
7536
7537async fn validate_execution_payload_key_inventory(
7538 transaction: &mut Transaction<'_, Postgres>,
7539 keys: &PayloadKeySet,
7540) -> anyhow::Result<()> {
7541 let envelopes = sqlx::query_scalar::<_, Vec<u8>>(
7542 "SELECT DISTINCT substring(sealed_transaction FROM 1 FOR 33) \
7543 FROM execution_transaction_hash \
7544 WHERE sealed_transaction IS NOT NULL",
7545 )
7546 .fetch_all(&mut **transaction)
7547 .await
7548 .context("failed to inspect execution payload key inventory")?;
7549
7550 for header in envelopes {
7551 anyhow::ensure!(
7552 header.len() == 33,
7553 "Stored execution payload has a truncated envelope header"
7554 );
7555 let mut envelope = header;
7556 envelope.extend_from_slice(&[0; 12 + 16]);
7557 let key_id = envelope_key_id(&envelope)?;
7558 anyhow::ensure!(
7559 keys.contains_key(&key_id),
7560 "Stored execution payload requires unavailable key {}",
7561 alloy::hex::encode(key_id)
7562 );
7563 }
7564 Ok(())
7565}
7566
7567async fn load_execution_intent(
7568 transaction: &mut Transaction<'_, Postgres>,
7569 intent_id: i64,
7570) -> anyhow::Result<ExecutionIntentRow> {
7571 sqlx::query_as::<_, ExecutionIntentRow>(
7572 "
7573 SELECT
7574 id, schema_version, chain_id, wallet_address, nonce, purpose, status,
7575 client_order_id, trader_id, strategy_id, account_id, instrument_id,
7576 pool_address, transaction_to, transaction_input, transaction_value,
7577 amount_in, created_block, acknowledgement_emitted, fill_emitted,
7578 terminal_emitted, active
7579 FROM execution_intent
7580 WHERE id = $1
7581 FOR SHARE
7582 ",
7583 )
7584 .bind(intent_id)
7585 .fetch_optional(&mut **transaction)
7586 .await
7587 .context("failed to load execution intent for payload authentication")?
7588 .ok_or_else(|| anyhow::anyhow!("Execution intent {intent_id} was not found"))
7589}
7590
7591fn execution_transition_allowed(current: &str, next: TransactionStatus) -> bool {
7592 if current == next.as_str() {
7593 return true;
7594 }
7595
7596 match current {
7597 "prepared" => matches!(
7598 next,
7599 TransactionStatus::Signed | TransactionStatus::Recoverable
7600 ),
7601 "signed" | "broadcast" => matches!(
7602 next,
7603 TransactionStatus::Broadcast
7604 | TransactionStatus::Included
7605 | TransactionStatus::Finalized
7606 | TransactionStatus::Reverted
7607 | TransactionStatus::Replaced
7608 | TransactionStatus::Dropped
7609 | TransactionStatus::Reorged
7610 ),
7611 "included" => matches!(
7612 next,
7613 TransactionStatus::Finalized
7614 | TransactionStatus::Reverted
7615 | TransactionStatus::Reorged
7616 | TransactionStatus::Replaced
7617 | TransactionStatus::Dropped
7618 ),
7619 "replaced" | "dropped" | "reorged" => matches!(
7620 next,
7621 TransactionStatus::Included
7622 | TransactionStatus::Finalized
7623 | TransactionStatus::Reverted
7624 | TransactionStatus::Replaced
7625 | TransactionStatus::Dropped
7626 | TransactionStatus::Reorged
7627 ),
7628 "finalized" | "reverted" | "recoverable" => false,
7629 _ => false,
7630 }
7631}
7632
7633#[cfg(test)]
7634pub(crate) mod tests {
7635 use std::time::Duration;
7636
7637 use anyhow::Context;
7638 use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
7639
7640 use super::{BlockchainCacheDatabase, ExecutionVerifiedHeader};
7641 use crate::rpc::verification::VERIFICATION_SCHEMA_VERSION;
7642
7643 pub(crate) struct ExecutionVerificationResume {
7644 pub next_canonical_nonce: u64,
7645 pub revision: u64,
7646 pub finalized_headers: Vec<ExecutionVerifiedHeader>,
7647 }
7648
7649 pub(crate) async fn connect_test_database(
7650 pg_options: PgConnectOptions,
7651 ) -> anyhow::Result<BlockchainCacheDatabase> {
7652 let pool = PgPoolOptions::new()
7653 .max_connections(32)
7654 .min_connections(1)
7655 .acquire_timeout(Duration::from_secs(3))
7656 .connect_with(pg_options)
7657 .await?;
7658 Ok(BlockchainCacheDatabase { pool })
7659 }
7660
7661 impl BlockchainCacheDatabase {
7662 pub(crate) async fn load_execution_verification_resume(
7664 &self,
7665 chain_id: u32,
7666 wallet_address: &str,
7667 manifest_version: &str,
7668 manifest_digest: &str,
7669 ) -> anyhow::Result<Option<ExecutionVerificationResume>> {
7670 let installed = sqlx::query_scalar::<_, i16>(
7671 "SELECT version FROM execution_schema_version \
7672 WHERE component = 'evm_execution_verification'",
7673 )
7674 .fetch_optional(&self.pool)
7675 .await
7676 .context("failed to inspect execution verification schema")?;
7677 let Some(installed) = installed else {
7678 return Ok(None);
7679 };
7680 anyhow::ensure!(
7681 installed <= VERIFICATION_SCHEMA_VERSION,
7682 "Unsupported execution verification schema version {installed}"
7683 );
7684 let chain_id = i32::try_from(chain_id)
7685 .context("Verification chain ID exceeds PostgreSQL INTEGER")?;
7686 let current = sqlx::query_as::<_, (String, String, i64, i64)>(
7687 "
7688 SELECT manifest_version, manifest_digest, next_canonical_nonce, revision
7689 FROM execution_verification_nonce
7690 WHERE chain_id = $1 AND wallet_address = $2
7691 ",
7692 )
7693 .bind(chain_id)
7694 .bind(wallet_address)
7695 .fetch_optional(&self.pool)
7696 .await
7697 .context("failed to load execution verification nonce position")?;
7698 let Some((stored_version, stored_digest, nonce, revision)) = current else {
7699 return Ok(None);
7700 };
7701 anyhow::ensure!(
7702 stored_version == manifest_version && stored_digest == manifest_digest,
7703 "Execution verification manifest identity changed"
7704 );
7705 let rows = sqlx::query_as::<_, (i64, String, String, i64, Option<String>, String)>(
7706 "
7707 SELECT number, hash, parent_hash, timestamp, base_fee_per_gas, manifest_digest
7708 FROM execution_verified_finalized_header
7709 WHERE chain_id = $1 AND wallet_address = $2
7710 ORDER BY number
7711 ",
7712 )
7713 .bind(chain_id)
7714 .bind(wallet_address)
7715 .fetch_all(&self.pool)
7716 .await
7717 .context("failed to load verified finalized header ledger")?;
7718 let finalized_headers = rows
7719 .into_iter()
7720 .map(|(number, hash, parent_hash, timestamp, base_fee, digest)| {
7721 anyhow::ensure!(
7722 digest == manifest_digest,
7723 "Finalized header manifest identity changed"
7724 );
7725 Ok(ExecutionVerifiedHeader {
7726 number: u64::try_from(number)
7727 .context("Finalized header number is negative")?,
7728 hash,
7729 parent_hash,
7730 timestamp: u64::try_from(timestamp)
7731 .context("Finalized header timestamp is negative")?,
7732 base_fee_per_gas: base_fee
7733 .map(|value| {
7734 value.parse::<u128>().map_err(|_| {
7735 anyhow::anyhow!("Finalized header base fee is invalid")
7736 })
7737 })
7738 .transpose()?,
7739 })
7740 })
7741 .collect::<anyhow::Result<Vec<_>>>()?;
7742 anyhow::ensure!(
7743 !finalized_headers.is_empty()
7744 && finalized_headers.windows(2).all(|headers| {
7745 headers[1].number == headers[0].number.saturating_add(1)
7746 && headers[1].parent_hash == headers[0].hash
7747 }),
7748 "Verified finalized header ledger is not continuous"
7749 );
7750 Ok(Some(ExecutionVerificationResume {
7751 next_canonical_nonce: u64::try_from(nonce)
7752 .context("Canonical nonce is negative")?,
7753 revision: u64::try_from(revision)
7754 .context("Canonical nonce revision is negative")?,
7755 finalized_headers,
7756 }))
7757 }
7758 }
7759
7760 #[tokio::test]
7761 async fn connect_returns_err_for_unreachable_database() {
7762 let options = PgConnectOptions::new()
7765 .host("127.0.0.1")
7766 .port(1)
7767 .username("nautilus")
7768 .password("pass")
7769 .database("nautilus");
7770
7771 let result = BlockchainCacheDatabase::connect(options).await;
7772
7773 assert!(result.is_err());
7774 }
7775}