// file: kb_lib/src/dex_pool_materialization.rs //! Shared DEX pool materialization helpers. //! //! This module persists normalized pool, pair, pool token and pool listing //! records from decoded DEX events. //! //! It intentionally does not decode instructions and does not record analysis //! signals. Detection services remain responsible for deciding which signals //! to emit. /// Token ordering strategy used when materializing a decoded DEX pool. #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub(crate) enum DexPoolTokenOrder { /// `token_a_mint` is already the base token and `token_b_mint` is already the quote token. AlreadyBaseQuote, /// Base/quote order should be chosen from token A/B using known quote-token hints. ChooseBaseQuoteFromTokenAB, } /// Input required to materialize a normalized DEX pool. #[derive(Debug, Clone)] pub(crate) struct DexPoolMaterializationInput { /// Parent decoded event id. pub(crate) decoded_event_id: i64, /// Internal DEX id. pub(crate) dex_id: i64, /// Pool account address. pub(crate) pool_address: std::string::String, /// Token A mint, or already-base mint depending on `token_order`. pub(crate) token_a_mint: std::string::String, /// Token B mint, or already-quote mint depending on `token_order`. pub(crate) token_b_mint: std::string::String, /// Optional LP mint. pub(crate) lp_mint: std::option::Option, /// Optional token A vault address, or base vault when `token_order` is `AlreadyBaseQuote`. pub(crate) token_a_vault_address: std::option::Option, /// Optional token B vault address, or quote vault when `token_order` is `AlreadyBaseQuote`. pub(crate) token_b_vault_address: std::option::Option, /// Pool kind to persist. pub(crate) pool_kind: crate::PoolKind, /// Pool status to persist. pub(crate) pool_status: crate::PoolStatus, /// Token ordering strategy. pub(crate) token_order: crate::dex_pool_materialization::DexPoolTokenOrder, /// Listing source kind. pub(crate) listing_source_kind: crate::ObservationSourceKind, /// Optional source endpoint logical name. pub(crate) source_endpoint_name: std::option::Option, } impl DexPoolMaterializationInput { /// Creates a materialization input from a decoded event requiring pool and two token mints. pub(crate) fn from_decoded_event( decoded_event: &crate::DexDecodedEventDto, dex_id: i64, pool_kind: crate::PoolKind, pool_status: crate::PoolStatus, token_order: crate::dex_pool_materialization::DexPoolTokenOrder, token_a_vault_address: std::option::Option, token_b_vault_address: std::option::Option, source_endpoint_name: std::option::Option, ) -> Result { let decoded_event_id_result = crate::dex_pool_materialization::required_decoded_event_id(decoded_event); let decoded_event_id = match decoded_event_id_result { Ok(decoded_event_id) => decoded_event_id, Err(error) => return Err(error), }; let pool_address_result = crate::dex_pool_materialization::required_pool_account(decoded_event); let pool_address = match pool_address_result { Ok(pool_address) => pool_address, Err(error) => return Err(error), }; let token_a_mint_result = crate::dex_pool_materialization::required_token_a_mint(decoded_event); let token_a_mint = match token_a_mint_result { Ok(token_a_mint) => token_a_mint, Err(error) => return Err(error), }; let token_b_mint_result = crate::dex_pool_materialization::required_token_b_mint(decoded_event); let token_b_mint = match token_b_mint_result { Ok(token_b_mint) => token_b_mint, Err(error) => return Err(error), }; return Ok(Self { decoded_event_id, dex_id, pool_address, token_a_mint, token_b_mint, lp_mint: decoded_event.lp_mint.clone(), token_a_vault_address, token_b_vault_address, pool_kind, pool_status, token_order, listing_source_kind: crate::ObservationSourceKind::Dex, source_endpoint_name, }); } /// Creates a materialization input from a decoded event and explicit token mints. /// /// This is used by launch or bonding-curve events where the decoded event /// may expose only the launched token mint and the quote mint is inferred /// by the detector. pub(crate) fn from_decoded_event_with_mints( decoded_event: &crate::DexDecodedEventDto, dex_id: i64, token_a_mint: std::string::String, token_b_mint: std::string::String, lp_mint: std::option::Option, pool_kind: crate::PoolKind, pool_status: crate::PoolStatus, token_order: crate::dex_pool_materialization::DexPoolTokenOrder, token_a_vault_address: std::option::Option, token_b_vault_address: std::option::Option, source_endpoint_name: std::option::Option, ) -> Result { let decoded_event_id_result = crate::dex_pool_materialization::required_decoded_event_id(decoded_event); let decoded_event_id = match decoded_event_id_result { Ok(decoded_event_id) => decoded_event_id, Err(error) => return Err(error), }; let pool_address_result = crate::dex_pool_materialization::required_pool_account(decoded_event); let pool_address = match pool_address_result { Ok(pool_address) => pool_address, Err(error) => return Err(error), }; return Ok(Self { decoded_event_id, dex_id, pool_address, token_a_mint, token_b_mint, lp_mint, token_a_vault_address, token_b_vault_address, pool_kind, pool_status, token_order, listing_source_kind: crate::ObservationSourceKind::Dex, source_endpoint_name, }); } } /// Returns the decoded event id or fails with a stable diagnostic. pub(crate) fn required_decoded_event_id( decoded_event: &crate::DexDecodedEventDto, ) -> Result { match decoded_event.id { Some(decoded_event_id) => return Ok(decoded_event_id), None => { return Err(crate::Error::InvalidState( "decoded dex event has no internal id".to_string(), )); }, } } /// Returns the pool account or fails with a stable diagnostic. pub(crate) fn required_pool_account( decoded_event: &crate::DexDecodedEventDto, ) -> Result { let decoded_event_id_result = crate::dex_pool_materialization::required_decoded_event_id(decoded_event); let decoded_event_id = match decoded_event_id_result { Ok(decoded_event_id) => decoded_event_id, Err(error) => return Err(error), }; match decoded_event.pool_account.clone() { Some(pool_account) => return Ok(pool_account), None => { return Err(crate::Error::InvalidState(format!( "decoded event '{}' has no pool_account", decoded_event_id ))); }, } } /// Returns token A mint or fails with a stable diagnostic. pub(crate) fn required_token_a_mint( decoded_event: &crate::DexDecodedEventDto, ) -> Result { let decoded_event_id_result = crate::dex_pool_materialization::required_decoded_event_id(decoded_event); let decoded_event_id = match decoded_event_id_result { Ok(decoded_event_id) => decoded_event_id, Err(error) => return Err(error), }; match decoded_event.token_a_mint.clone() { Some(token_a_mint) => return Ok(token_a_mint), None => { return Err(crate::Error::InvalidState(format!( "decoded event '{}' has no token_a_mint", decoded_event_id ))); }, } } /// Returns token B mint or fails with a stable diagnostic. pub(crate) fn required_token_b_mint( decoded_event: &crate::DexDecodedEventDto, ) -> Result { let decoded_event_id_result = crate::dex_pool_materialization::required_decoded_event_id(decoded_event); let decoded_event_id = match decoded_event_id_result { Ok(decoded_event_id) => decoded_event_id, Err(error) => return Err(error), }; match decoded_event.token_b_mint.clone() { Some(token_b_mint) => return Ok(token_b_mint), None => { return Err(crate::Error::InvalidState(format!( "decoded event '{}' has no token_b_mint", decoded_event_id ))); }, } } /// Persists pool, pair, pool tokens and listing, returning the materialized ids. pub(crate) async fn materialize_dex_pool( database: &crate::Database, input: &crate::dex_pool_materialization::DexPoolMaterializationInput, ) -> Result { let ordered_tokens = crate::dex_pool_materialization::ordered_pool_tokens_from_input(input); let base_token_id_result = crate::dex_pool_materialization::ensure_token(database, ordered_tokens.base_mint.as_str()) .await; let base_token_id = match base_token_id_result { Ok(base_token_id) => base_token_id, Err(error) => return Err(error), }; let quote_token_id_result = crate::dex_pool_materialization::ensure_token(database, ordered_tokens.quote_mint.as_str()) .await; let quote_token_id = match quote_token_id_result { Ok(quote_token_id) => quote_token_id, Err(error) => return Err(error), }; let lp_token_id = match input.lp_mint.clone() { Some(lp_mint) => { let lp_token_id_result = crate::dex_pool_materialization::ensure_token(database, lp_mint.as_str()).await; match lp_token_id_result { Ok(lp_token_id) => Some(lp_token_id), Err(error) => return Err(error), } }, None => None, }; let pool_result = crate::dex_pool_materialization::ensure_pool(database, input).await; let pool_materialization = match pool_result { Ok(pool_materialization) => pool_materialization, Err(error) => return Err(error), }; let pair_result = crate::dex_pool_materialization::ensure_pair( database, input.dex_id, pool_materialization.pool_id, base_token_id, quote_token_id, ordered_tokens.base_mint.as_str(), ordered_tokens.quote_mint.as_str(), ) .await; let pair_materialization = match pair_result { Ok(pair_materialization) => pair_materialization, Err(error) => return Err(error), }; let upsert_base_pool_token_result = crate::query_pool_tokens_upsert( database, &crate::PoolTokenDto::new( pool_materialization.pool_id, base_token_id, crate::PoolTokenRole::Base, ordered_tokens.base_vault_address, Some(0), ), ) .await; if let Err(error) = upsert_base_pool_token_result { return Err(error); } let upsert_quote_pool_token_result = crate::query_pool_tokens_upsert( database, &crate::PoolTokenDto::new( pool_materialization.pool_id, quote_token_id, crate::PoolTokenRole::Quote, ordered_tokens.quote_vault_address, Some(1), ), ) .await; if let Err(error) = upsert_quote_pool_token_result { return Err(error); } if let Some(lp_token_id) = lp_token_id { let upsert_lp_pool_token_result = crate::query_pool_tokens_upsert( database, &crate::PoolTokenDto::new( pool_materialization.pool_id, lp_token_id, crate::PoolTokenRole::LpMint, None, None, ), ) .await; if let Err(error) = upsert_lp_pool_token_result { return Err(error); } } let listing_result = crate::dex_pool_materialization::ensure_pool_listing( database, input, pool_materialization.pool_id, pair_materialization.pair_id, ) .await; let listing_materialization = match listing_result { Ok(listing_materialization) => listing_materialization, Err(error) => return Err(error), }; return Ok(crate::DexPoolDetectionResult { decoded_event_id: input.decoded_event_id, dex_id: input.dex_id, pool_id: pool_materialization.pool_id, pair_id: pair_materialization.pair_id, pool_listing_id: listing_materialization.pool_listing_id, created_pool: pool_materialization.created_pool, created_pair: pair_materialization.created_pair, created_listing: listing_materialization.created_listing, }); } #[derive(Debug, Clone)] struct OrderedPoolTokens { base_mint: std::string::String, quote_mint: std::string::String, base_vault_address: std::option::Option, quote_vault_address: std::option::Option, } fn ordered_pool_tokens_from_input( input: &crate::dex_pool_materialization::DexPoolMaterializationInput, ) -> OrderedPoolTokens { match input.token_order { crate::dex_pool_materialization::DexPoolTokenOrder::AlreadyBaseQuote => { return OrderedPoolTokens { base_mint: input.token_a_mint.clone(), quote_mint: input.token_b_mint.clone(), base_vault_address: input.token_a_vault_address.clone(), quote_vault_address: input.token_b_vault_address.clone(), }; }, crate::dex_pool_materialization::DexPoolTokenOrder::ChooseBaseQuoteFromTokenAB => { let base_is_token_a = crate::dex_pool_materialization::choose_base_quote_order( input.token_a_mint.as_str(), input.token_b_mint.as_str(), ); let base_mint = if base_is_token_a { input.token_a_mint.clone() } else { input.token_b_mint.clone() }; let quote_mint = if base_is_token_a { input.token_b_mint.clone() } else { input.token_a_mint.clone() }; let base_vault_address = if base_is_token_a { input.token_a_vault_address.clone() } else { input.token_b_vault_address.clone() }; let quote_vault_address = if base_is_token_a { input.token_b_vault_address.clone() } else { input.token_a_vault_address.clone() }; return OrderedPoolTokens { base_mint, quote_mint, base_vault_address, quote_vault_address, }; }, } } async fn ensure_pool( database: &crate::Database, input: &crate::dex_pool_materialization::DexPoolMaterializationInput, ) -> Result { let existing_pool_result = crate::query_pools_get_by_address(database, input.pool_address.as_str()).await; let existing_pool_option = match existing_pool_result { Ok(existing_pool_option) => existing_pool_option, Err(error) => return Err(error), }; let created_pool = existing_pool_option.is_none(); let pool_id = match existing_pool_option { Some(pool) => match pool.id { Some(pool_id) => pool_id, None => { return Err(crate::Error::InvalidState(format!( "pool '{}' has no internal id", pool.address ))); }, }, None => { let pool_dto = crate::PoolDto::new( input.dex_id, input.pool_address.clone(), input.pool_kind, input.pool_status, ); let upsert_result = crate::query_pools_upsert(database, &pool_dto).await; match upsert_result { Ok(pool_id) => pool_id, Err(error) => return Err(error), } }, }; return Ok(PoolMaterialization { pool_id, created_pool }); } async fn ensure_pair( database: &crate::Database, dex_id: i64, pool_id: i64, base_token_id: i64, quote_token_id: i64, base_mint: &str, quote_mint: &str, ) -> Result { let existing_pair_result = crate::query_pairs_get_by_pool_id(database, pool_id).await; let existing_pair_option = match existing_pair_result { Ok(existing_pair_option) => existing_pair_option, Err(error) => return Err(error), }; let created_pair = existing_pair_option.is_none(); let pair_symbol = crate::dex_pool_materialization::build_pair_symbol(base_mint, quote_mint); let pair_id = match existing_pair_option { Some(pair) => match pair.id { Some(pair_id) => pair_id, None => { return Err(crate::Error::InvalidState(format!( "pair for pool '{}' has no internal id", pool_id ))); }, }, None => { let pair_dto = crate::PairDto::new(dex_id, pool_id, base_token_id, quote_token_id, pair_symbol); let upsert_result = crate::query_pairs_upsert(database, &pair_dto).await; match upsert_result { Ok(pair_id) => pair_id, Err(error) => return Err(error), } }, }; return Ok(PairMaterialization { pair_id, created_pair }); } async fn ensure_pool_listing( database: &crate::Database, input: &crate::dex_pool_materialization::DexPoolMaterializationInput, pool_id: i64, pair_id: i64, ) -> Result { let existing_listing_result = crate::query_pool_listings_get_by_pool_id(database, pool_id).await; let existing_listing_option = match existing_listing_result { Ok(existing_listing_option) => existing_listing_option, Err(error) => return Err(error), }; let created_listing = existing_listing_option.is_none(); let pool_listing_id = match existing_listing_option { Some(pool_listing) => pool_listing.id, None => { let listing_dto = crate::PoolListingDto::new( input.dex_id, pool_id, Some(pair_id), input.listing_source_kind, input.source_endpoint_name.clone(), None, None, None, ); let upsert_result = crate::query_pool_listings_upsert(database, &listing_dto).await; match upsert_result { Ok(listing_id) => Some(listing_id), Err(error) => return Err(error), } }, }; return Ok(ListingMaterialization { pool_listing_id, created_listing }); } async fn ensure_token(database: &crate::Database, mint: &str) -> Result { let token_result = crate::query_tokens_get_by_mint(database, mint).await; let token_option = match token_result { Ok(token_option) => token_option, Err(error) => return Err(error), }; match token_option { Some(token) => match token.id { Some(token_id) => return Ok(token_id), None => { return Err(crate::Error::InvalidState(format!( "token '{}' has no internal id", mint ))); }, }, None => { let token_dto = crate::TokenDto::new( mint.to_string(), None, None, None, crate::SPL_TOKEN_PROGRAM_ID.to_string(), crate::dex_pool_materialization::is_quote_mint(mint), ); return crate::query_tokens_upsert(database, &token_dto).await; }, } } #[derive(Debug, Clone)] struct PoolMaterialization { pool_id: i64, created_pool: bool, } #[derive(Debug, Clone)] struct PairMaterialization { pair_id: i64, created_pair: bool, } #[derive(Debug, Clone)] struct ListingMaterialization { pool_listing_id: std::option::Option, created_listing: bool, } fn is_quote_mint(mint: &str) -> bool { return mint == crate::WSOL_MINT_ID; } fn choose_base_quote_order(token_a_mint: &str, token_b_mint: &str) -> bool { let token_a_is_quote = crate::dex_pool_materialization::is_quote_mint(token_a_mint); let token_b_is_quote = crate::dex_pool_materialization::is_quote_mint(token_b_mint); if token_a_is_quote && !token_b_is_quote { return false; } if token_b_is_quote && !token_a_is_quote { return true; } return true; } fn build_pair_symbol( base_mint: &str, quote_mint: &str, ) -> std::option::Option { let base_symbol = crate::dex_pool_materialization::symbol_hint_from_mint(base_mint); let quote_symbol = crate::dex_pool_materialization::symbol_hint_from_mint(quote_mint); match (base_symbol, quote_symbol) { (Some(base_symbol), Some(quote_symbol)) => { return Some(format!("{base_symbol}/{quote_symbol}")); }, _ => return None, } } fn symbol_hint_from_mint(mint: &str) -> std::option::Option { if mint == crate::WSOL_MINT_ID { return Some("WSOL".to_string()); } return None; } #[cfg(test)] mod tests { #[test] fn quote_token_is_moved_to_quote_side_when_order_is_chosen() { let input = crate::dex_pool_materialization::DexPoolMaterializationInput { decoded_event_id: 1, dex_id: 2, pool_address: "Pool111".to_string(), token_a_mint: crate::WSOL_MINT_ID.to_string(), token_b_mint: "TokenB111".to_string(), lp_mint: None, token_a_vault_address: Some("VaultA111".to_string()), token_b_vault_address: Some("VaultB111".to_string()), pool_kind: crate::PoolKind::Amm, pool_status: crate::PoolStatus::Active, token_order: crate::dex_pool_materialization::DexPoolTokenOrder::ChooseBaseQuoteFromTokenAB, listing_source_kind: crate::ObservationSourceKind::Dex, source_endpoint_name: Some("test".to_string()), }; let ordered = super::ordered_pool_tokens_from_input(&input); assert_eq!(ordered.base_mint, "TokenB111"); assert_eq!(ordered.quote_mint, crate::WSOL_MINT_ID); assert_eq!(ordered.base_vault_address, Some("VaultB111".to_string())); assert_eq!(ordered.quote_vault_address, Some("VaultA111".to_string())); } #[test] fn already_base_quote_order_is_preserved() { let input = crate::dex_pool_materialization::DexPoolMaterializationInput { decoded_event_id: 1, dex_id: 2, pool_address: "Pool111".to_string(), token_a_mint: crate::WSOL_MINT_ID.to_string(), token_b_mint: "TokenB111".to_string(), lp_mint: None, token_a_vault_address: Some("BaseVault111".to_string()), token_b_vault_address: Some("QuoteVault111".to_string()), pool_kind: crate::PoolKind::Amm, pool_status: crate::PoolStatus::Active, token_order: crate::dex_pool_materialization::DexPoolTokenOrder::AlreadyBaseQuote, listing_source_kind: crate::ObservationSourceKind::Dex, source_endpoint_name: Some("test".to_string()), }; let ordered = super::ordered_pool_tokens_from_input(&input); assert_eq!(ordered.base_mint, crate::WSOL_MINT_ID); assert_eq!(ordered.quote_mint, "TokenB111"); assert_eq!(ordered.base_vault_address, Some("BaseVault111".to_string())); assert_eq!(ordered.quote_vault_address, Some("QuoteVault111".to_string())); } #[test] fn pair_symbol_is_none_when_only_one_symbol_is_known() { let pair_symbol = super::build_pair_symbol(crate::WSOL_MINT_ID, "TokenB111"); assert_eq!(pair_symbol, None); } #[test] fn wsol_symbol_hint_is_known() { let symbol = super::symbol_hint_from_mint(crate::WSOL_MINT_ID); assert_eq!(symbol, Some("WSOL".to_string())); } }