1060 lines
35 KiB
Rust
1060 lines
35 KiB
Rust
// file: kb_lib/src/dex_event_classification.rs
|
|
|
|
//! Shared DEX event classification and decoded-payload enrichment.
|
|
//!
|
|
//! This module contains deterministic helpers used by DEX decoding,
|
|
//! trade aggregation and future non-trade event materialization.
|
|
//!
|
|
//! It intentionally does not decode protocol instructions and does not
|
|
//! perform database access.
|
|
|
|
/// Stable business category assigned to one decoded DEX event kind.
|
|
#[derive(Debug, Copy, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub enum DexEventCategory {
|
|
/// Swap-like event that can potentially become a normalized trade.
|
|
Trade,
|
|
/// Liquidity deposit, withdraw, position open or position close event.
|
|
Liquidity,
|
|
/// Fee collection event.
|
|
Fee,
|
|
/// Reward or emission event.
|
|
Reward,
|
|
/// Pool creation, initialization or migration event.
|
|
PoolLifecycle,
|
|
/// Protocol administration, configuration or permission update event.
|
|
Admin,
|
|
/// Event kind that is not classified yet.
|
|
Unknown,
|
|
}
|
|
|
|
impl DexEventCategory {
|
|
/// Returns the stable string code persisted inside decoded payload metadata.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Trade => return "trade",
|
|
Self::Liquidity => return "liquidity",
|
|
Self::Fee => return "fee",
|
|
Self::Reward => return "reward",
|
|
Self::PoolLifecycle => return "pool_lifecycle",
|
|
Self::Admin => return "admin",
|
|
Self::Unknown => return "unknown",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fine-grained lifecycle kind assigned to one decoded DEX event kind.
|
|
#[derive(Debug, Copy, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub enum DexEventLifecycleKind {
|
|
/// Swap-like trade event.
|
|
TradeSwap,
|
|
/// Pool creation or initialization event.
|
|
PoolCreation,
|
|
/// Pair creation event when it can be distinguished from pool creation.
|
|
PairCreation,
|
|
/// Liquidity deposit or add-liquidity event.
|
|
LiquidityAdd,
|
|
/// Liquidity withdraw or remove-liquidity event.
|
|
LiquidityRemove,
|
|
/// Concentrated-liquidity position open event.
|
|
PositionOpen,
|
|
/// Concentrated-liquidity position close event.
|
|
PositionClose,
|
|
/// Migration event, for example launch surface to AMM/CLMM/DLMM.
|
|
Migration,
|
|
/// Launch or bonding-curve initialization event.
|
|
Launch,
|
|
/// Token mint event detected through a DEX or launch surface decoder.
|
|
Mint,
|
|
/// Token burn event detected through a DEX or launch surface decoder.
|
|
Burn,
|
|
/// Fee collection event.
|
|
FeeCollection,
|
|
/// Reward or emission event.
|
|
Reward,
|
|
/// Administration, configuration or permission update event.
|
|
AdminConfig,
|
|
/// Event kind that is not classified yet.
|
|
Unknown,
|
|
}
|
|
|
|
impl DexEventLifecycleKind {
|
|
/// Returns the stable string code persisted inside decoded payload metadata.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::TradeSwap => return "trade_swap",
|
|
Self::PoolCreation => return "pool_creation",
|
|
Self::PairCreation => return "pair_creation",
|
|
Self::LiquidityAdd => return "liquidity_add",
|
|
Self::LiquidityRemove => return "liquidity_remove",
|
|
Self::PositionOpen => return "position_open",
|
|
Self::PositionClose => return "position_close",
|
|
Self::Migration => return "migration",
|
|
Self::Launch => return "launch",
|
|
Self::Mint => return "mint",
|
|
Self::Burn => return "burn",
|
|
Self::FeeCollection => return "fee_collection",
|
|
Self::Reward => return "reward",
|
|
Self::AdminConfig => return "admin_config",
|
|
Self::Unknown => return "unknown",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stable actionability class assigned to one decoded DEX event.
|
|
#[derive(Debug, Copy, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub enum DexEventActionability {
|
|
/// Direct swap-like event that can feed trade/candle materialization.
|
|
TradeCandidate,
|
|
/// Swap-like event detected but not materializable as trade/candle yet.
|
|
NonActionableTrade,
|
|
/// Useful non-trade event that should remain visible for future materialization.
|
|
NonTradeUseful,
|
|
/// Failed transaction event retained for diagnostics but never actionable.
|
|
FailedTransaction,
|
|
/// Classified event that is informational only for the current pipeline.
|
|
Informational,
|
|
/// Event that is not classified yet.
|
|
Unknown,
|
|
}
|
|
|
|
impl DexEventActionability {
|
|
/// Returns the stable string code persisted inside decoded payload metadata.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::TradeCandidate => return "trade_candidate",
|
|
Self::NonActionableTrade => return "non_actionable_trade",
|
|
Self::NonTradeUseful => return "non_trade_useful",
|
|
Self::FailedTransaction => return "failed_transaction",
|
|
Self::Informational => return "informational",
|
|
Self::Unknown => return "unknown",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Classifies a DEX event kind into a stable business category.
|
|
pub fn classify_dex_event_category(event_kind: &str) -> DexEventCategory {
|
|
if is_dex_reward_event_kind(event_kind) {
|
|
return DexEventCategory::Reward;
|
|
}
|
|
if is_dex_fee_event_kind(event_kind) {
|
|
return DexEventCategory::Fee;
|
|
}
|
|
if is_dex_liquidity_event_kind(event_kind) {
|
|
return DexEventCategory::Liquidity;
|
|
}
|
|
if is_dex_pool_lifecycle_event_kind(event_kind) {
|
|
return DexEventCategory::PoolLifecycle;
|
|
}
|
|
if is_dex_admin_event_kind(event_kind) {
|
|
return DexEventCategory::Admin;
|
|
}
|
|
if is_dex_trade_event_kind(event_kind) {
|
|
return DexEventCategory::Trade;
|
|
}
|
|
return DexEventCategory::Unknown;
|
|
}
|
|
|
|
/// Classifies a DEX event kind and returns the persisted category code.
|
|
pub fn classify_dex_event_category_code(event_kind: &str) -> &'static str {
|
|
return classify_dex_event_category(event_kind).as_str();
|
|
}
|
|
|
|
/// Classifies a DEX event kind into a fine-grained lifecycle kind.
|
|
pub fn classify_dex_event_lifecycle_kind(event_kind: &str) -> DexEventLifecycleKind {
|
|
if is_dex_token_burn_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::Burn;
|
|
}
|
|
if is_dex_token_mint_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::Mint;
|
|
}
|
|
if is_dex_migration_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::Migration;
|
|
}
|
|
if is_dex_launch_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::Launch;
|
|
}
|
|
if is_dex_pair_creation_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::PairCreation;
|
|
}
|
|
if is_dex_pool_creation_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::PoolCreation;
|
|
}
|
|
if is_dex_liquidity_add_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::LiquidityAdd;
|
|
}
|
|
if is_dex_liquidity_remove_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::LiquidityRemove;
|
|
}
|
|
if is_dex_position_open_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::PositionOpen;
|
|
}
|
|
if is_dex_position_close_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::PositionClose;
|
|
}
|
|
if is_dex_fee_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::FeeCollection;
|
|
}
|
|
if is_dex_reward_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::Reward;
|
|
}
|
|
if is_dex_admin_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::AdminConfig;
|
|
}
|
|
if is_dex_trade_event_kind(event_kind) {
|
|
return DexEventLifecycleKind::TradeSwap;
|
|
}
|
|
return DexEventLifecycleKind::Unknown;
|
|
}
|
|
|
|
/// Classifies a DEX event kind and returns the persisted lifecycle kind code.
|
|
pub fn classify_dex_event_lifecycle_kind_code(event_kind: &str) -> &'static str {
|
|
return classify_dex_event_lifecycle_kind(event_kind).as_str();
|
|
}
|
|
|
|
/// Classifies one decoded DEX event actionability from its kind and candidate flags.
|
|
pub fn classify_dex_event_actionability(
|
|
event_kind: &str,
|
|
trade_candidate: bool,
|
|
transaction_failed: bool,
|
|
) -> DexEventActionability {
|
|
if transaction_failed {
|
|
return DexEventActionability::FailedTransaction;
|
|
}
|
|
if trade_candidate {
|
|
return DexEventActionability::TradeCandidate;
|
|
}
|
|
if is_dex_trade_event_kind(event_kind) {
|
|
return DexEventActionability::NonActionableTrade;
|
|
}
|
|
let category = classify_dex_event_category(event_kind);
|
|
match category {
|
|
DexEventCategory::Liquidity => return DexEventActionability::NonTradeUseful,
|
|
DexEventCategory::Fee => return DexEventActionability::NonTradeUseful,
|
|
DexEventCategory::Reward => return DexEventActionability::NonTradeUseful,
|
|
DexEventCategory::PoolLifecycle => return DexEventActionability::NonTradeUseful,
|
|
DexEventCategory::Admin => return DexEventActionability::NonTradeUseful,
|
|
DexEventCategory::Trade => return DexEventActionability::NonActionableTrade,
|
|
DexEventCategory::Unknown => return DexEventActionability::Unknown,
|
|
}
|
|
}
|
|
|
|
/// Classifies one decoded DEX event actionability and returns its persisted code.
|
|
pub fn classify_dex_event_actionability_code(
|
|
event_kind: &str,
|
|
trade_candidate: bool,
|
|
transaction_failed: bool,
|
|
) -> &'static str {
|
|
return classify_dex_event_actionability(event_kind, trade_candidate, transaction_failed)
|
|
.as_str();
|
|
}
|
|
|
|
/// Returns true when the event kind represents a swap-like event.
|
|
pub fn is_dex_trade_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.ends_with(".buy") {
|
|
return true;
|
|
}
|
|
if event_kind.ends_with(".sell") {
|
|
return true;
|
|
}
|
|
if event_kind.ends_with(".swap") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".swap_") {
|
|
return true;
|
|
}
|
|
if event_kind.ends_with(".exact_input") {
|
|
return true;
|
|
}
|
|
if event_kind.ends_with(".exact_output") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true when the event kind can directly produce a candle candidate.
|
|
pub fn is_dex_candle_candidate_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains("router") {
|
|
return false;
|
|
}
|
|
if event_kind.contains("route") {
|
|
return false;
|
|
}
|
|
return is_dex_trade_event_kind(event_kind);
|
|
}
|
|
|
|
/// Returns true for liquidity lifecycle changes that must not become candles.
|
|
pub fn is_dex_liquidity_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains(".deposit") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".withdraw") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".add_liquidity") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".remove_liquidity") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".increase_liquidity") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".decrease_liquidity") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".initialize_position") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".open_position") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".close_position") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for liquidity add-like DEX events.
|
|
pub fn is_dex_liquidity_add_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains(".deposit") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".add_liquidity") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".increase_liquidity") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for liquidity remove-like DEX events.
|
|
pub fn is_dex_liquidity_remove_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains(".withdraw") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".remove_liquidity") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".decrease_liquidity") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for concentrated-liquidity position open events.
|
|
pub fn is_dex_position_open_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains(".initialize_position") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".open_position") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for concentrated-liquidity position close events.
|
|
pub fn is_dex_position_close_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains(".close_position") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for fee collection events.
|
|
pub fn is_dex_fee_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains("collect_creator_fee") {
|
|
return true;
|
|
}
|
|
if event_kind.contains("collect_protocol_fee") {
|
|
return true;
|
|
}
|
|
if event_kind.contains("collect_fund_fee") {
|
|
return true;
|
|
}
|
|
if event_kind.contains("collect_fee") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for reward or incentive events.
|
|
pub fn is_dex_reward_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains("reward") {
|
|
return true;
|
|
}
|
|
if event_kind.contains("emission") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for pool, pair, launch, mint, burn or migration lifecycle events.
|
|
pub fn is_dex_pool_lifecycle_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains(".initialize_bin_array") {
|
|
return true;
|
|
}
|
|
if is_dex_pool_creation_event_kind(event_kind) {
|
|
return true;
|
|
}
|
|
if is_dex_pair_creation_event_kind(event_kind) {
|
|
return true;
|
|
}
|
|
if is_dex_launch_event_kind(event_kind) {
|
|
return true;
|
|
}
|
|
if is_dex_token_mint_event_kind(event_kind) {
|
|
return true;
|
|
}
|
|
if is_dex_token_burn_event_kind(event_kind) {
|
|
return true;
|
|
}
|
|
if is_dex_migration_event_kind(event_kind) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for launch or bonding-curve creation events.
|
|
pub fn is_dex_launch_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains("pump_fun.create") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".launch") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".create_v2_token") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".create_bonding_curve") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for token mint events detected by DEX or launch-surface decoders.
|
|
pub fn is_dex_token_mint_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains(".mint") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".token_mint") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for token burn events detected by DEX or launch-surface decoders.
|
|
pub fn is_dex_token_burn_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains(".burn") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".token_burn") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for launch-surface or pool migration events.
|
|
pub fn is_dex_migration_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains(".migrate") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".migration") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for pool creation or initialization events.
|
|
pub fn is_dex_pool_creation_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains(".initialize_position") {
|
|
return false;
|
|
}
|
|
if event_kind.contains(".initialize_bin_array") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".initialize") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".initialize_with_permission") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".create_pool") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".create_amm") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for pair creation events when they are distinguishable from pool creation.
|
|
pub fn is_dex_pair_creation_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains(".create_pair") {
|
|
return true;
|
|
}
|
|
if event_kind.contains(".pair_create") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true for admin, configuration or permission changes.
|
|
pub fn is_dex_admin_event_kind(event_kind: &str) -> bool {
|
|
if event_kind.contains("admin") {
|
|
return true;
|
|
}
|
|
if event_kind.contains("config") {
|
|
return true;
|
|
}
|
|
if event_kind.contains("permission") {
|
|
return true;
|
|
}
|
|
if event_kind.contains("set_") {
|
|
return true;
|
|
}
|
|
if event_kind.contains("update_") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Returns true when a decoded payload contains at least one direct amount or price field.
|
|
///
|
|
/// This is a conservative payload-level check. It does not inspect transaction
|
|
/// token balance deltas and is intended for protocol decoders that cannot yet
|
|
/// produce a deterministic materializable swap payload.
|
|
pub(crate) fn decoded_payload_has_trade_amount_or_price_payload(
|
|
payload: &serde_json::Value,
|
|
) -> bool {
|
|
return value_contains_any_non_null_key(
|
|
payload,
|
|
&[
|
|
"baseAmountRaw",
|
|
"base_amount_raw",
|
|
"baseAmount",
|
|
"amountBase",
|
|
"amountInBase",
|
|
"quoteAmountRaw",
|
|
"quote_amount_raw",
|
|
"quoteAmount",
|
|
"amountQuote",
|
|
"amountOutQuote",
|
|
"amountIn",
|
|
"amountOut",
|
|
"priceQuotePerBase",
|
|
"price_quote_per_base",
|
|
"quotePerBase",
|
|
"lastPriceQuotePerBase",
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Returns true when a decoded payload is marked as a trade candidate.
|
|
///
|
|
/// Explicit payload metadata wins over event-kind inference. This allows
|
|
/// incomplete decoded events, such as incomplete PumpSwap trades, to opt out.
|
|
pub fn is_decoded_event_trade_candidate(event_kind: &str, payload: &serde_json::Value) -> bool {
|
|
let trade_candidate_option =
|
|
extract_top_level_bool_by_candidate_keys(payload, &["tradeCandidate", "trade_candidate"]);
|
|
if let Some(trade_candidate) = trade_candidate_option {
|
|
return trade_candidate;
|
|
}
|
|
let event_category_option =
|
|
extract_string_by_candidate_keys(payload, &["eventCategory", "event_category"]);
|
|
if let Some(event_category) = event_category_option {
|
|
return event_category.as_str() == DexEventCategory::Trade.as_str();
|
|
}
|
|
return is_dex_trade_event_kind(event_kind);
|
|
}
|
|
|
|
/// Returns true when a decoded payload can be materialized as a candle candidate.
|
|
pub fn is_decoded_event_candle_candidate(event_kind: &str, payload: &serde_json::Value) -> bool {
|
|
let candle_candidate_option =
|
|
extract_top_level_bool_by_candidate_keys(payload, &["candleCandidate", "candle_candidate"]);
|
|
if let Some(candle_candidate) = candle_candidate_option {
|
|
return candle_candidate;
|
|
}
|
|
if !is_decoded_event_trade_candidate(event_kind, payload) {
|
|
return false;
|
|
}
|
|
return is_dex_candle_candidate_event_kind(event_kind);
|
|
}
|
|
|
|
/// Enriches a decoded payload with non-destructive classification metadata.
|
|
pub fn enrich_dex_decoded_payload(
|
|
protocol_name: &str,
|
|
event_kind: &str,
|
|
payload_json: serde_json::Value,
|
|
) -> serde_json::Value {
|
|
let event_category = classify_dex_event_category_code(event_kind);
|
|
let event_lifecycle_kind = classify_dex_event_lifecycle_kind_code(event_kind);
|
|
let mut object = match payload_json {
|
|
serde_json::Value::Object(object) => object,
|
|
other => {
|
|
let mut object = serde_json::Map::new();
|
|
object.insert("rawPayload".to_owned(), other);
|
|
object
|
|
},
|
|
};
|
|
let payload_snapshot = serde_json::Value::Object(object.clone());
|
|
let explicit_trade_candidate = extract_top_level_bool_by_candidate_keys(
|
|
&payload_snapshot,
|
|
&["tradeCandidate", "trade_candidate"],
|
|
);
|
|
let trade_candidate = match explicit_trade_candidate {
|
|
Some(trade_candidate) => trade_candidate,
|
|
None => is_dex_trade_event_kind(event_kind),
|
|
};
|
|
let explicit_candle_candidate = extract_top_level_bool_by_candidate_keys(
|
|
&payload_snapshot,
|
|
&["candleCandidate", "candle_candidate"],
|
|
);
|
|
let candle_candidate = match explicit_candle_candidate {
|
|
Some(candle_candidate) => candle_candidate,
|
|
None => {
|
|
if !trade_candidate {
|
|
false
|
|
} else {
|
|
is_dex_candle_candidate_event_kind(event_kind)
|
|
}
|
|
},
|
|
};
|
|
let transaction_failed = match extract_top_level_bool_by_candidate_keys(
|
|
&payload_snapshot,
|
|
&["transactionFailed", "transaction_failed"],
|
|
) {
|
|
Some(transaction_failed) => transaction_failed,
|
|
None => false,
|
|
};
|
|
let event_actionability =
|
|
classify_dex_event_actionability_code(event_kind, trade_candidate, transaction_failed);
|
|
let non_trade_useful = event_actionability == DexEventActionability::NonTradeUseful.as_str();
|
|
json_insert_string_if_missing(&mut object, "protocolName", protocol_name);
|
|
json_insert_string_if_missing(&mut object, "eventKind", event_kind);
|
|
json_insert_string_if_missing(&mut object, "eventCategory", event_category);
|
|
json_insert_string_if_missing(&mut object, "eventLifecycleKind", event_lifecycle_kind);
|
|
json_insert_string_if_missing(&mut object, "eventActionability", event_actionability);
|
|
json_insert_bool_if_missing(&mut object, "nonTradeUseful", non_trade_useful);
|
|
json_insert_bool_if_missing(&mut object, "tradeCandidate", trade_candidate);
|
|
json_insert_bool_if_missing(&mut object, "candleCandidate", candle_candidate);
|
|
json_insert_i64_if_missing(&mut object, "eventClassificationVersion", 2);
|
|
if !trade_candidate {
|
|
if is_dex_trade_event_kind(event_kind) {
|
|
json_insert_string_if_missing(&mut object, "skipTradeReason", "non_actionable_trade");
|
|
} else {
|
|
json_insert_string_if_missing(&mut object, "skipTradeReason", "non_trade_event");
|
|
}
|
|
} else if !candle_candidate {
|
|
json_insert_string_if_missing(
|
|
&mut object,
|
|
"skipCandleReason",
|
|
"route_or_multihop_event_requires_leg_resolution",
|
|
);
|
|
}
|
|
return serde_json::Value::Object(object);
|
|
}
|
|
|
|
/// Enriches a decoded payload and serializes it as JSON.
|
|
pub fn enrich_and_serialize_dex_decoded_payload(
|
|
protocol_name: &str,
|
|
event_kind: &str,
|
|
payload_json: serde_json::Value,
|
|
) -> Result<std::string::String, crate::Error> {
|
|
let enriched_payload = enrich_dex_decoded_payload(protocol_name, event_kind, payload_json);
|
|
let payload_json_result = serde_json::to_string(&enriched_payload);
|
|
match payload_json_result {
|
|
Ok(payload_json) => return Ok(payload_json),
|
|
Err(error) => {
|
|
return Err(crate::Error::Json(format!(
|
|
"cannot serialize enriched decoded payload for '{}': {}",
|
|
event_kind, error
|
|
)));
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Parses, enriches and serializes a decoded payload.
|
|
pub fn enrich_serialized_dex_decoded_payload(
|
|
protocol_name: &str,
|
|
event_kind: &str,
|
|
payload_json: &str,
|
|
) -> Result<std::string::String, crate::Error> {
|
|
let payload_value_result = serde_json::from_str::<serde_json::Value>(payload_json);
|
|
let payload_value = match payload_value_result {
|
|
Ok(payload_value) => payload_value,
|
|
Err(error) => {
|
|
return Err(crate::Error::Json(format!(
|
|
"cannot parse decoded payload for '{}': {}",
|
|
event_kind, error
|
|
)));
|
|
},
|
|
};
|
|
return enrich_and_serialize_dex_decoded_payload(protocol_name, event_kind, payload_value);
|
|
}
|
|
|
|
fn json_insert_string_if_missing(
|
|
object: &mut serde_json::Map<std::string::String, serde_json::Value>,
|
|
key: &str,
|
|
value: &str,
|
|
) {
|
|
if object.contains_key(key) {
|
|
return;
|
|
}
|
|
object.insert(key.to_owned(), serde_json::Value::String(value.to_owned()));
|
|
}
|
|
|
|
fn json_insert_bool_if_missing(
|
|
object: &mut serde_json::Map<std::string::String, serde_json::Value>,
|
|
key: &str,
|
|
value: bool,
|
|
) {
|
|
if object.contains_key(key) {
|
|
return;
|
|
}
|
|
object.insert(key.to_owned(), serde_json::Value::Bool(value));
|
|
}
|
|
|
|
fn json_insert_i64_if_missing(
|
|
object: &mut serde_json::Map<std::string::String, serde_json::Value>,
|
|
key: &str,
|
|
value: i64,
|
|
) {
|
|
if object.contains_key(key) {
|
|
return;
|
|
}
|
|
object.insert(key.to_owned(), serde_json::Value::Number(serde_json::Number::from(value)));
|
|
}
|
|
|
|
fn value_contains_any_non_null_key(value: &serde_json::Value, candidate_keys: &[&str]) -> bool {
|
|
if let Some(object) = value.as_object() {
|
|
for candidate_key in candidate_keys {
|
|
let candidate_value_option = object.get(*candidate_key);
|
|
if let Some(candidate_value) = candidate_value_option {
|
|
if !candidate_value.is_null() {
|
|
if let Some(text) = candidate_value.as_str() {
|
|
if text.trim().is_empty() {
|
|
continue;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
for nested_value in object.values() {
|
|
if value_contains_any_non_null_key(nested_value, candidate_keys) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
if let Some(array) = value.as_array() {
|
|
for nested_value in array {
|
|
if value_contains_any_non_null_key(nested_value, candidate_keys) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
fn extract_top_level_bool_by_candidate_keys(
|
|
payload: &serde_json::Value,
|
|
candidate_keys: &[&str],
|
|
) -> std::option::Option<bool> {
|
|
let object = match payload.as_object() {
|
|
Some(object) => object,
|
|
None => return None,
|
|
};
|
|
for candidate_key in candidate_keys {
|
|
let value_option = object.get(*candidate_key);
|
|
let value = match value_option {
|
|
Some(value) => value,
|
|
None => continue,
|
|
};
|
|
if let Some(value_bool) = value.as_bool() {
|
|
return Some(value_bool);
|
|
}
|
|
if let Some(value_i64) = value.as_i64() {
|
|
return Some(value_i64 != 0);
|
|
}
|
|
if let Some(value_u64) = value.as_u64() {
|
|
return Some(value_u64 != 0);
|
|
}
|
|
if let Some(value_text) = value.as_str() {
|
|
let normalized = value_text.trim().to_ascii_lowercase();
|
|
if normalized.as_str() == "true" {
|
|
return Some(true);
|
|
}
|
|
if normalized.as_str() == "false" {
|
|
return Some(false);
|
|
}
|
|
if normalized.as_str() == "1" {
|
|
return Some(true);
|
|
}
|
|
if normalized.as_str() == "0" {
|
|
return Some(false);
|
|
}
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
|
|
fn extract_string_by_candidate_keys(
|
|
value: &serde_json::Value,
|
|
candidate_keys: &[&str],
|
|
) -> std::option::Option<std::string::String> {
|
|
if let Some(object) = value.as_object() {
|
|
for candidate_key in candidate_keys {
|
|
let direct_option = object.get(*candidate_key);
|
|
if let Some(direct) = direct_option {
|
|
let direct_text_option = direct.as_str();
|
|
if let Some(direct_text) = direct_text_option {
|
|
return Some(direct_text.to_string());
|
|
}
|
|
}
|
|
}
|
|
for nested_value in object.values() {
|
|
let nested_result = extract_string_by_candidate_keys(nested_value, candidate_keys);
|
|
if nested_result.is_some() {
|
|
return nested_result;
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
if let Some(array) = value.as_array() {
|
|
for nested_value in array {
|
|
let nested_result = extract_string_by_candidate_keys(nested_value, candidate_keys);
|
|
if nested_result.is_some() {
|
|
return nested_result;
|
|
}
|
|
}
|
|
}
|
|
|
|
return None;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn classifies_swap_events_as_trade_candidates() {
|
|
assert_eq!(
|
|
super::classify_dex_event_category_code("raydium_cpmm.swap_base_input"),
|
|
"trade"
|
|
);
|
|
assert_eq!(
|
|
super::classify_dex_event_category_code("raydium_cpmm.swap_base_output"),
|
|
"trade"
|
|
);
|
|
assert_eq!(super::classify_dex_event_category_code("raydium_clmm.swap"), "trade");
|
|
assert_eq!(super::classify_dex_event_category_code("raydium_clmm.swap_v2"), "trade");
|
|
assert_eq!(super::classify_dex_event_category_code("raydium_clmm.exact_output"), "trade");
|
|
assert_eq!(super::classify_dex_event_category_code("pump_fun.buy"), "trade");
|
|
assert!(super::is_dex_trade_event_kind("raydium_cpmm.swap_base_input"));
|
|
assert!(super::is_dex_candle_candidate_event_kind("raydium_cpmm.swap_base_input"));
|
|
}
|
|
|
|
#[test]
|
|
fn classifies_router_swap_as_trade_but_not_direct_candle_candidate() {
|
|
assert_eq!(
|
|
super::classify_dex_event_category_code("raydium_clmm.swap_router_base_in"),
|
|
"trade"
|
|
);
|
|
assert!(super::is_dex_trade_event_kind("raydium_clmm.swap_router_base_in"));
|
|
assert!(!super::is_dex_candle_candidate_event_kind("raydium_clmm.swap_router_base_in"));
|
|
}
|
|
|
|
#[test]
|
|
fn classifies_fee_reward_liquidity_and_lifecycle_events() {
|
|
assert_eq!(
|
|
super::classify_dex_event_category_code("raydium_cpmm.collect_creator_fee"),
|
|
"fee"
|
|
);
|
|
assert_eq!(
|
|
super::classify_dex_event_category_code("raydium_clmm.collect_protocol_fee"),
|
|
"fee"
|
|
);
|
|
assert_eq!(
|
|
super::classify_dex_event_category_code("raydium_clmm.set_reward_params"),
|
|
"reward"
|
|
);
|
|
assert_eq!(
|
|
super::classify_dex_event_category_code("raydium_clmm.increase_liquidity_v2"),
|
|
"liquidity"
|
|
);
|
|
assert_eq!(
|
|
super::classify_dex_event_category_code("raydium_cpmm.initialize"),
|
|
"pool_lifecycle"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classifies_fine_grained_non_trade_lifecycle_kinds() {
|
|
assert_eq!(
|
|
super::classify_dex_event_lifecycle_kind_code("raydium_cpmm.initialize"),
|
|
"pool_creation"
|
|
);
|
|
assert_eq!(super::classify_dex_event_lifecycle_kind_code("pump_fun.create"), "launch");
|
|
assert_eq!(
|
|
super::classify_dex_event_lifecycle_kind_code("meteora_dbc.migrate"),
|
|
"migration"
|
|
);
|
|
assert_eq!(
|
|
super::classify_dex_event_lifecycle_kind_code("raydium_clmm.increase_liquidity_v2"),
|
|
"liquidity_add"
|
|
);
|
|
assert_eq!(
|
|
super::classify_dex_event_lifecycle_kind_code("raydium_clmm.decrease_liquidity_v2"),
|
|
"liquidity_remove"
|
|
);
|
|
assert_eq!(
|
|
super::classify_dex_event_actionability_code(
|
|
"raydium_clmm.increase_liquidity_v2",
|
|
false,
|
|
false,
|
|
),
|
|
"non_trade_useful"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enriched_payload_keeps_existing_fields() {
|
|
let payload_json = serde_json::json!({
|
|
"eventCategory": "custom",
|
|
"amountIn": "10"
|
|
});
|
|
let enriched_payload = super::enrich_dex_decoded_payload(
|
|
"raydium_cpmm",
|
|
"raydium_cpmm.swap_base_input",
|
|
payload_json,
|
|
);
|
|
|
|
let object_option = enriched_payload.as_object();
|
|
let object = match object_option {
|
|
Some(object) => object,
|
|
None => {
|
|
panic!("expected enriched payload object");
|
|
},
|
|
};
|
|
assert_eq!(
|
|
object.get("eventCategory"),
|
|
Some(&serde_json::Value::String("custom".to_owned()))
|
|
);
|
|
assert_eq!(
|
|
object.get("protocolName"),
|
|
Some(&serde_json::Value::String("raydium_cpmm".to_owned()))
|
|
);
|
|
assert_eq!(
|
|
object.get("eventKind"),
|
|
Some(&serde_json::Value::String("raydium_cpmm.swap_base_input".to_owned()))
|
|
);
|
|
assert_eq!(object.get("tradeCandidate"), Some(&serde_json::Value::Bool(true)));
|
|
assert_eq!(object.get("candleCandidate"), Some(&serde_json::Value::Bool(true)));
|
|
assert_eq!(
|
|
object.get("eventLifecycleKind"),
|
|
Some(&serde_json::Value::String("trade_swap".to_owned()))
|
|
);
|
|
assert_eq!(
|
|
object.get("eventActionability"),
|
|
Some(&serde_json::Value::String("trade_candidate".to_owned()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enriched_non_trade_payload_is_visible_but_not_trade_candidate() {
|
|
let enriched_payload = super::enrich_dex_decoded_payload(
|
|
"raydium_clmm",
|
|
"raydium_clmm.increase_liquidity_v2",
|
|
serde_json::json!({}),
|
|
);
|
|
let object_option = enriched_payload.as_object();
|
|
let object = match object_option {
|
|
Some(object) => object,
|
|
None => {
|
|
panic!("expected enriched payload object");
|
|
},
|
|
};
|
|
assert_eq!(
|
|
object.get("eventCategory"),
|
|
Some(&serde_json::Value::String("liquidity".to_owned()))
|
|
);
|
|
assert_eq!(
|
|
object.get("eventLifecycleKind"),
|
|
Some(&serde_json::Value::String("liquidity_add".to_owned()))
|
|
);
|
|
assert_eq!(
|
|
object.get("eventActionability"),
|
|
Some(&serde_json::Value::String("non_trade_useful".to_owned()))
|
|
);
|
|
assert_eq!(object.get("nonTradeUseful"), Some(&serde_json::Value::Bool(true)));
|
|
assert_eq!(object.get("tradeCandidate"), Some(&serde_json::Value::Bool(false)));
|
|
assert_eq!(object.get("candleCandidate"), Some(&serde_json::Value::Bool(false)));
|
|
}
|
|
|
|
#[test]
|
|
fn decoded_event_payload_candidate_flags_win_over_event_kind() {
|
|
let payload_json = serde_json::json!({
|
|
"tradeCandidate": false,
|
|
"candleCandidate": false
|
|
});
|
|
assert!(!super::is_decoded_event_trade_candidate("pump_swap.buy", &payload_json));
|
|
assert!(!super::is_decoded_event_candle_candidate("pump_swap.buy", &payload_json));
|
|
}
|
|
|
|
#[test]
|
|
fn detects_direct_amount_or_price_payload_recursively() {
|
|
let payload_json = serde_json::json!({
|
|
"nested": {
|
|
"quoteAmountRaw": "2500"
|
|
}
|
|
});
|
|
assert!(super::decoded_payload_has_trade_amount_or_price_payload(&payload_json));
|
|
let empty_payload_json = serde_json::json!({
|
|
"nested": {
|
|
"quoteAmountRaw": ""
|
|
}
|
|
});
|
|
assert!(!super::decoded_payload_has_trade_amount_or_price_payload(&empty_payload_json));
|
|
}
|
|
|
|
#[test]
|
|
fn classifies_dlmm_add_remove_liquidity_and_positions_as_non_trade_useful() {
|
|
assert_eq!(
|
|
super::classify_dex_event_category_code("meteora_dlmm.add_liquidity"),
|
|
"liquidity"
|
|
);
|
|
assert_eq!(
|
|
super::classify_dex_event_category_code("meteora_dlmm.remove_liquidity"),
|
|
"liquidity"
|
|
);
|
|
assert_eq!(
|
|
super::classify_dex_event_lifecycle_kind_code("meteora_dlmm.initialize_position"),
|
|
"position_open"
|
|
);
|
|
assert_eq!(
|
|
super::classify_dex_event_actionability_code(
|
|
"meteora_dlmm.add_liquidity",
|
|
false,
|
|
false,
|
|
),
|
|
"non_trade_useful"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classifies_dlmm_bin_array_initialization_as_pool_lifecycle() {
|
|
assert_eq!(
|
|
super::classify_dex_event_category_code("meteora_dlmm.initialize_bin_array"),
|
|
"pool_lifecycle"
|
|
);
|
|
assert_eq!(
|
|
super::classify_dex_event_actionability_code(
|
|
"meteora_dlmm.initialize_bin_array",
|
|
false,
|
|
false,
|
|
),
|
|
"non_trade_useful"
|
|
);
|
|
}
|
|
}
|