Skip to main content

nautilus_blockchain/cache/
rows.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{num::ParseIntError, str::FromStr};
17
18use alloy::primitives::{Address, I256, U160, U256};
19use nautilus_core::{
20    UnixNanos,
21    datetime::{NANOSECONDS_IN_MICROSECOND, NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND},
22};
23use nautilus_model::{
24    defi::{
25        PoolLiquidityUpdate, PoolLiquidityUpdateType, PoolSwap, SharedChain, SharedDex,
26        data::{
27            DexPoolData, PoolFeeCollect, PoolFeeProtocolCollect, PoolFeeProtocolUpdate, PoolFlash,
28        },
29        validation::validate_address,
30    },
31    identifiers::InstrumentId,
32};
33use sqlx::{FromRow, Row, postgres::PgRow};
34
35const MAX_UNIX_SECONDS_TIMESTAMP: u64 = 9_999_999_999;
36const MAX_UNIX_MILLISECONDS_TIMESTAMP: u64 = MAX_UNIX_SECONDS_TIMESTAMP * 1_000 + 999;
37const MAX_UNIX_MICROSECONDS_TIMESTAMP: u64 = MAX_UNIX_SECONDS_TIMESTAMP * 1_000_000 + 999_999;
38
39/// A data transfer object that maps database rows to token data.
40///
41/// Implements `FromRow` trait to automatically convert PostgreSQL results into `TokenRow`
42/// objects that can be transformed into domain entity `Token` objects.
43#[derive(Debug)]
44pub struct TokenRow {
45    pub address: Address,
46    pub name: String,
47    pub symbol: String,
48    pub decimals: i32,
49}
50
51impl<'r> FromRow<'r, PgRow> for TokenRow {
52    fn from_row(row: &'r PgRow) -> Result<Self, sqlx::Error> {
53        let address = validate_address(row.try_get::<String, _>("address")?.as_str()).unwrap();
54        let name = row.try_get::<String, _>("name")?;
55        let symbol = row.try_get::<String, _>("symbol")?;
56        let decimals = row.try_get::<i32, _>("decimals")?;
57
58        let token = Self {
59            address,
60            name,
61            symbol,
62            decimals,
63        };
64        Ok(token)
65    }
66}
67
68#[derive(Debug)]
69pub struct PoolRow {
70    pub address: Address,
71    pub pool_identifier: String,
72    pub dex_name: String,
73    pub creation_block: i64,
74    pub creation_block_timestamp: Option<UnixNanos>,
75    pub token0_chain: i32,
76    pub token0_address: Address,
77    pub token1_chain: i32,
78    pub token1_address: Address,
79    pub fee: Option<i32>,
80    pub tick_spacing: Option<i32>,
81    pub initial_tick: Option<i32>,
82    pub initial_sqrt_price_x96: Option<String>,
83    pub hook_address: Option<String>,
84}
85
86impl<'r> FromRow<'r, PgRow> for PoolRow {
87    fn from_row(row: &'r PgRow) -> Result<Self, sqlx::Error> {
88        let address = validate_address(row.try_get::<String, _>("address")?.as_str()).unwrap();
89        let pool_identifier = row.try_get::<String, _>("pool_identifier")?;
90        let dex_name = row.try_get::<String, _>("dex_name")?;
91        let creation_block = row.try_get::<i64, _>("creation_block")?;
92        let creation_block_timestamp =
93            row.try_get::<Option<String>, _>("creation_block_timestamp")?;
94        let creation_block_timestamp = creation_block_timestamp
95            .as_deref()
96            .map(parse_cached_block_timestamp)
97            .transpose()
98            .map_err(|e| {
99                sqlx::Error::Decode(
100                    format!("Invalid creation block timestamp '{creation_block_timestamp:?}': {e}")
101                        .into(),
102                )
103            })?;
104        let token0_chain = row.try_get::<i32, _>("token0_chain")?;
105        let token0_address =
106            validate_address(row.try_get::<String, _>("token0_address")?.as_str()).unwrap();
107        let token1_chain = row.try_get::<i32, _>("token1_chain")?;
108        let token1_address =
109            validate_address(row.try_get::<String, _>("token1_address")?.as_str()).unwrap();
110        let fee = row.try_get::<Option<i32>, _>("fee")?;
111        let tick_spacing = row.try_get::<Option<i32>, _>("tick_spacing")?;
112        let initial_tick = row.try_get::<Option<i32>, _>("initial_tick")?;
113        let initial_sqrt_price_x96 = row.try_get::<Option<String>, _>("initial_sqrt_price_x96")?;
114        let hook_address = row.try_get::<Option<String>, _>("hook_address")?;
115
116        Ok(Self {
117            address,
118            pool_identifier,
119            dex_name,
120            creation_block,
121            creation_block_timestamp,
122            token0_chain,
123            token0_address,
124            token1_chain,
125            token1_address,
126            fee,
127            tick_spacing,
128            initial_tick,
129            initial_sqrt_price_x96,
130            hook_address,
131        })
132    }
133}
134
135/// A data transfer object that maps database rows to block timestamp data.
136#[derive(Debug)]
137pub struct BlockTimestampRow {
138    /// The block number.
139    pub number: u64,
140    /// The block timestamp.
141    pub timestamp: UnixNanos,
142}
143
144impl FromRow<'_, PgRow> for BlockTimestampRow {
145    fn from_row(row: &PgRow) -> Result<Self, sqlx::Error> {
146        let number = row.try_get::<i64, _>("number")? as u64;
147        let timestamp = row.try_get::<String, _>("timestamp")?;
148        let timestamp = parse_cached_block_timestamp(&timestamp).map_err(|e| {
149            sqlx::Error::Decode(format!("Invalid block timestamp '{timestamp}': {e}").into())
150        })?;
151        Ok(Self { number, timestamp })
152    }
153}
154
155pub(crate) fn parse_cached_block_timestamp(value: &str) -> Result<UnixNanos, ParseIntError> {
156    let timestamp = value.parse::<u64>()?;
157    if timestamp <= MAX_UNIX_SECONDS_TIMESTAMP {
158        return Ok(UnixNanos::from(timestamp * NANOSECONDS_IN_SECOND));
159    }
160
161    if timestamp <= MAX_UNIX_MILLISECONDS_TIMESTAMP {
162        return Ok(UnixNanos::from(timestamp * NANOSECONDS_IN_MILLISECOND));
163    }
164
165    if timestamp <= MAX_UNIX_MICROSECONDS_TIMESTAMP {
166        return Ok(UnixNanos::from(timestamp * NANOSECONDS_IN_MICROSECOND));
167    }
168
169    Ok(UnixNanos::from(timestamp))
170}
171
172/// Transforms a database row from the pool events UNION query into a DexPoolData enum variant.
173///
174/// This function directly processes a PostgreSQL row and creates the appropriate DexPoolData
175/// variant based on the event_type discriminator field, using the provided context.
176///
177/// # Errors
178///
179/// Returns an error if row field extraction fails or data validation fails.
180pub fn transform_row_to_dex_pool_data(
181    row: &PgRow,
182    chain: SharedChain,
183    dex: SharedDex,
184    instrument_id: InstrumentId,
185) -> Result<DexPoolData, sqlx::Error> {
186    let event_type = row.try_get::<String, _>("event_type")?;
187    let pool_identifier_str = row.try_get::<String, _>("pool_identifier")?;
188    let pool_identifier = pool_identifier_str
189        .parse()
190        .map_err(|e| sqlx::Error::Decode(format!("Invalid pool identifier: {e}").into()))?;
191    let block = row.try_get::<i64, _>("block")? as u64;
192    let transaction_hash = row.try_get::<String, _>("transaction_hash")?;
193    let transaction_index = row.try_get::<i32, _>("transaction_index")? as u32;
194    let log_index = row.try_get::<i32, _>("log_index")? as u32;
195    let block_timestamp = row.try_get::<String, _>("block_timestamp")?;
196    let timestamp = parse_cached_block_timestamp(&block_timestamp).map_err(|e| {
197        sqlx::Error::Decode(format!("Invalid block timestamp '{block_timestamp}': {e}").into())
198    })?;
199
200    match event_type.as_str() {
201        "swap" => {
202            let sender_str = row
203                .try_get::<Option<String>, _>("sender")?
204                .ok_or_else(|| sqlx::Error::Decode("Missing sender for swap event".into()))?;
205            let sender = validate_address(&sender_str)
206                .map_err(|e| sqlx::Error::Decode(e.to_string().into()))?;
207
208            let recipient_str = row
209                .try_get::<Option<String>, _>("recipient")?
210                .ok_or_else(|| sqlx::Error::Decode("Missing recipient for swap event".into()))?;
211            let recipient = validate_address(&recipient_str)
212                .map_err(|e| sqlx::Error::Decode(e.to_string().into()))?;
213
214            let sqrt_price_x96_str = row
215                .try_get::<Option<String>, _>("sqrt_price_x96")?
216                .ok_or_else(|| {
217                    sqlx::Error::Decode("Missing sqrt_price_x96 for swap event".into())
218                })?;
219            let sqrt_price_x96 = U160::from_str(&sqrt_price_x96_str).map_err(|e| {
220                sqlx::Error::Decode(
221                    format!("Invalid sqrt_price_x96 '{sqrt_price_x96_str}': {e}").into(),
222                )
223            })?;
224
225            let swap_liquidity_str = row.try_get::<String, _>("swap_liquidity")?;
226            let swap_liquidity = u128::from_str(&swap_liquidity_str)
227                .map_err(|e| sqlx::Error::Decode(e.to_string().into()))?;
228
229            let swap_tick = row.try_get::<i32, _>("swap_tick")?;
230
231            let swap_amount0_str = row
232                .try_get::<Option<String>, _>("swap_amount0")?
233                .ok_or_else(|| sqlx::Error::Decode("Missing swap_amount0 for swap event".into()))?;
234            let amount0 = I256::from_str(&swap_amount0_str).map_err(|e| {
235                sqlx::Error::Decode(
236                    format!("Invalid swap_amount0 '{swap_amount0_str}': {e}").into(),
237                )
238            })?;
239
240            let swap_amount1_str = row
241                .try_get::<Option<String>, _>("swap_amount1")?
242                .ok_or_else(|| sqlx::Error::Decode("Missing swap_amount1 for swap event".into()))?;
243            let amount1 = I256::from_str(&swap_amount1_str).map_err(|e| {
244                sqlx::Error::Decode(
245                    format!("Invalid swap_amount1 '{swap_amount1_str}': {e}").into(),
246                )
247            })?;
248
249            let pool_swap = PoolSwap::new(
250                chain,
251                dex,
252                instrument_id,
253                pool_identifier,
254                block,
255                transaction_hash,
256                transaction_index,
257                log_index,
258                timestamp, // ts_event
259                timestamp, // ts_init (same block timestamp)
260                sender,
261                recipient,
262                amount0,
263                amount1,
264                sqrt_price_x96,
265                swap_liquidity,
266                swap_tick,
267            );
268
269            Ok(DexPoolData::Swap(pool_swap))
270        }
271        "liquidity" => {
272            let kind_str = row
273                .try_get::<Option<String>, _>("liquidity_event_type")?
274                .ok_or_else(|| {
275                    sqlx::Error::Decode("Missing liquidity_event_type for liquidity event".into())
276                })?;
277
278            let kind = match kind_str.as_str() {
279                "Mint" => PoolLiquidityUpdateType::Mint,
280                "Burn" => PoolLiquidityUpdateType::Burn,
281                _ => {
282                    return Err(sqlx::Error::Decode(
283                        format!("Unknown liquidity update type: {kind_str}").into(),
284                    ));
285                }
286            };
287
288            let sender = row
289                .try_get::<Option<String>, _>("sender")?
290                .map(|s| validate_address(&s))
291                .transpose()
292                .map_err(|e| sqlx::Error::Decode(e.to_string().into()))?;
293
294            let owner_str = row
295                .try_get::<Option<String>, _>("owner")?
296                .ok_or_else(|| sqlx::Error::Decode("Missing owner for liquidity event".into()))?;
297            let owner = validate_address(&owner_str)
298                .map_err(|e| sqlx::Error::Decode(e.to_string().into()))?;
299
300            // UNION queries return NUMERIC type, not domain types, so we need to read as strings
301            let position_liquidity_str = row.try_get::<String, _>("position_liquidity")?;
302            let position_liquidity = position_liquidity_str.parse::<u128>().map_err(|e| {
303                sqlx::Error::Decode(
304                    format!("Invalid position_liquidity '{position_liquidity_str}': {e}").into(),
305                )
306            })?;
307
308            let amount0_str = row.try_get::<String, _>("amount0")?;
309            let amount0 = U256::from_str_radix(&amount0_str, 10).map_err(|e| {
310                sqlx::Error::Decode(format!("Invalid amount0 '{amount0_str}': {e}").into())
311            })?;
312
313            let amount1_str = row.try_get::<String, _>("amount1")?;
314            let amount1 = U256::from_str_radix(&amount1_str, 10).map_err(|e| {
315                sqlx::Error::Decode(format!("Invalid amount1 '{amount1_str}': {e}").into())
316            })?;
317
318            let tick_lower = row
319                .try_get::<Option<i32>, _>("tick_lower")?
320                .ok_or_else(|| {
321                    sqlx::Error::Decode("Missing tick_lower for liquidity event".into())
322                })?;
323
324            let tick_upper = row
325                .try_get::<Option<i32>, _>("tick_upper")?
326                .ok_or_else(|| {
327                    sqlx::Error::Decode("Missing tick_upper for liquidity event".into())
328                })?;
329
330            let pool_liquidity_update = PoolLiquidityUpdate::new(
331                chain,
332                dex,
333                instrument_id,
334                pool_identifier,
335                kind,
336                block,
337                transaction_hash,
338                transaction_index,
339                log_index,
340                sender,
341                owner,
342                position_liquidity,
343                amount0,
344                amount1,
345                tick_lower,
346                tick_upper,
347                timestamp, // ts_event
348                timestamp, // ts_init (same block timestamp)
349            );
350
351            Ok(DexPoolData::LiquidityUpdate(pool_liquidity_update))
352        }
353        "collect" => {
354            let owner_str = row
355                .try_get::<Option<String>, _>("owner")?
356                .ok_or_else(|| sqlx::Error::Decode("Missing owner for collect event".into()))?;
357            let owner = validate_address(&owner_str)
358                .map_err(|e| sqlx::Error::Decode(e.to_string().into()))?;
359
360            // UNION queries return NUMERIC type, not domain types, so we need to read as strings
361            let amount0_str = row.try_get::<String, _>("amount0")?;
362            let amount0 = amount0_str.parse::<u128>().map_err(|e| {
363                sqlx::Error::Decode(format!("Invalid amount0 '{amount0_str}': {e}").into())
364            })?;
365
366            let amount1_str = row.try_get::<String, _>("amount1")?;
367            let amount1 = amount1_str.parse::<u128>().map_err(|e| {
368                sqlx::Error::Decode(format!("Invalid amount1 '{amount1_str}': {e}").into())
369            })?;
370
371            let tick_lower = row
372                .try_get::<Option<i32>, _>("tick_lower")?
373                .ok_or_else(|| {
374                    sqlx::Error::Decode("Missing tick_lower for collect event".into())
375                })?;
376
377            let tick_upper = row
378                .try_get::<Option<i32>, _>("tick_upper")?
379                .ok_or_else(|| {
380                    sqlx::Error::Decode("Missing tick_upper for collect event".into())
381                })?;
382
383            let pool_fee_collect = PoolFeeCollect::new(
384                chain,
385                dex,
386                instrument_id,
387                pool_identifier,
388                block,
389                transaction_hash,
390                transaction_index,
391                log_index,
392                owner,
393                amount0,
394                amount1,
395                tick_lower,
396                tick_upper,
397                timestamp, // ts_event
398                timestamp, // ts_init (same block timestamp)
399            );
400
401            Ok(DexPoolData::FeeCollect(pool_fee_collect))
402        }
403        "fee_protocol_update" => {
404            let fee_protocol0_new = row.try_get::<i16, _>("fee_protocol0_new")?;
405            let fee_protocol1_new = row.try_get::<i16, _>("fee_protocol1_new")?;
406            let fee_protocol0_new = u8::try_from(fee_protocol0_new).map_err(|e| {
407                sqlx::Error::Decode(
408                    format!("Invalid fee_protocol0_new '{fee_protocol0_new}': {e}").into(),
409                )
410            })?;
411            let fee_protocol1_new = u8::try_from(fee_protocol1_new).map_err(|e| {
412                sqlx::Error::Decode(
413                    format!("Invalid fee_protocol1_new '{fee_protocol1_new}': {e}").into(),
414                )
415            })?;
416
417            let pool_fee_protocol_update = PoolFeeProtocolUpdate::new(
418                chain,
419                dex,
420                instrument_id,
421                pool_identifier,
422                block,
423                transaction_hash,
424                transaction_index,
425                log_index,
426                fee_protocol0_new,
427                fee_protocol1_new,
428                timestamp, // ts_event
429                timestamp, // ts_init (same block timestamp)
430            );
431
432            Ok(DexPoolData::FeeProtocolUpdate(pool_fee_protocol_update))
433        }
434        "fee_protocol_collect" => {
435            let sender_str = row.try_get::<Option<String>, _>("sender")?.ok_or_else(|| {
436                sqlx::Error::Decode("Missing sender for fee_protocol_collect event".into())
437            })?;
438            let sender = validate_address(&sender_str)
439                .map_err(|e| sqlx::Error::Decode(e.to_string().into()))?;
440
441            let recipient_str =
442                row.try_get::<Option<String>, _>("recipient")?
443                    .ok_or_else(|| {
444                        sqlx::Error::Decode(
445                            "Missing recipient for fee_protocol_collect event".into(),
446                        )
447                    })?;
448            let recipient = validate_address(&recipient_str)
449                .map_err(|e| sqlx::Error::Decode(e.to_string().into()))?;
450
451            // UNION queries return NUMERIC type, not domain types, so we need to read as strings
452            let amount0_str = row.try_get::<String, _>("amount0")?;
453            let amount0 = amount0_str.parse::<u128>().map_err(|e| {
454                sqlx::Error::Decode(format!("Invalid amount0 '{amount0_str}': {e}").into())
455            })?;
456
457            let amount1_str = row.try_get::<String, _>("amount1")?;
458            let amount1 = amount1_str.parse::<u128>().map_err(|e| {
459                sqlx::Error::Decode(format!("Invalid amount1 '{amount1_str}': {e}").into())
460            })?;
461
462            let pool_fee_protocol_collect = PoolFeeProtocolCollect::new(
463                chain,
464                dex,
465                instrument_id,
466                pool_identifier,
467                block,
468                transaction_hash,
469                transaction_index,
470                log_index,
471                sender,
472                recipient,
473                amount0,
474                amount1,
475                timestamp, // ts_event
476                timestamp, // ts_init (same block timestamp)
477            );
478
479            Ok(DexPoolData::FeeProtocolCollect(pool_fee_protocol_collect))
480        }
481        "flash" => {
482            let sender_str = row
483                .try_get::<Option<String>, _>("sender")?
484                .ok_or_else(|| sqlx::Error::Decode("Missing sender for flash event".into()))?;
485            let sender = validate_address(&sender_str)
486                .map_err(|e| sqlx::Error::Decode(e.to_string().into()))?;
487
488            let recipient_str = row
489                .try_get::<Option<String>, _>("recipient")?
490                .ok_or_else(|| sqlx::Error::Decode("Missing recipient for flash event".into()))?;
491            let recipient = validate_address(&recipient_str)
492                .map_err(|e| sqlx::Error::Decode(e.to_string().into()))?;
493
494            // For flash events, we have flash_amount0, flash_amount1, flash_paid0, flash_paid1
495            let flash_amount0_str = row.try_get::<String, _>("flash_amount0")?;
496            let amount0 = U256::from_str_radix(&flash_amount0_str, 10).map_err(|e| {
497                sqlx::Error::Decode(
498                    format!("Invalid flash_amount0 '{flash_amount0_str}': {e}").into(),
499                )
500            })?;
501
502            let flash_amount1_str = row.try_get::<String, _>("flash_amount1")?;
503            let amount1 = U256::from_str_radix(&flash_amount1_str, 10).map_err(|e| {
504                sqlx::Error::Decode(
505                    format!("Invalid flash_amount1 '{flash_amount1_str}': {e}").into(),
506                )
507            })?;
508
509            let flash_paid0_str = row.try_get::<String, _>("flash_paid0")?;
510            let paid0 = U256::from_str_radix(&flash_paid0_str, 10).map_err(|e| {
511                sqlx::Error::Decode(format!("Invalid flash_paid0 '{flash_paid0_str}': {e}").into())
512            })?;
513
514            let flash_paid1_str = row.try_get::<String, _>("flash_paid1")?;
515            let paid1 = U256::from_str_radix(&flash_paid1_str, 10).map_err(|e| {
516                sqlx::Error::Decode(format!("Invalid flash_paid1 '{flash_paid1_str}': {e}").into())
517            })?;
518
519            let pool_flash = PoolFlash::new(
520                chain,
521                dex,
522                instrument_id,
523                pool_identifier,
524                block,
525                transaction_hash,
526                transaction_index,
527                log_index,
528                timestamp, // ts_event
529                timestamp, // ts_init (same block timestamp)
530                sender,
531                recipient,
532                amount0,
533                amount1,
534                paid0,
535                paid1,
536            );
537
538            Ok(DexPoolData::Flash(pool_flash))
539        }
540        _ => Err(sqlx::Error::Decode(
541            format!("Unknown event type: {event_type}").into(),
542        )),
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use nautilus_core::datetime::{
549        NANOSECONDS_IN_MICROSECOND, NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND,
550    };
551    use rstest::rstest;
552
553    use super::*;
554
555    #[rstest]
556    #[case("1700000000", 1_700_000_000 * NANOSECONDS_IN_SECOND)]
557    #[case("9999999999", 9_999_999_999 * NANOSECONDS_IN_SECOND)]
558    #[case("1700000000123", 1_700_000_000_123 * NANOSECONDS_IN_MILLISECOND)]
559    #[case("9999999999999", 9_999_999_999_999 * NANOSECONDS_IN_MILLISECOND)]
560    #[case("1700000000123456", 1_700_000_000_123_456 * NANOSECONDS_IN_MICROSECOND)]
561    #[case("9999999999999999", 9_999_999_999_999_999 * NANOSECONDS_IN_MICROSECOND)]
562    #[case("1700000000123456789", 1_700_000_000_123_456_789)]
563    fn parse_cached_block_timestamp_returns_unix_nanos(#[case] value: &str, #[case] expected: u64) {
564        let timestamp = parse_cached_block_timestamp(value).unwrap();
565
566        assert_eq!(timestamp, UnixNanos::from(expected));
567    }
568
569    #[rstest]
570    fn parse_cached_block_timestamp_rejects_invalid_text() {
571        let result = parse_cached_block_timestamp("not-a-timestamp");
572
573        assert!(result.is_err());
574    }
575}