This commit is contained in:
2026-05-11 11:02:47 +02:00
parent d66afede28
commit 7f130dba6b
49 changed files with 10301 additions and 8481 deletions

View File

@@ -14,17 +14,134 @@ pub const SPL_TOKEN_2022_PROGRAM_ID: &str = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqC
/// @see solana_sdk::pubkey::Pubkey = spl_associated_token_account_interface::program::ID
pub const ASSOCIATED_TOKEN_PROGRAM_ID: &str = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
/// Wrapped SOL mint identifier. ("So11111111111111111111111111111111111111112").
/// @see solana_sdk::pubkey::Pubkey = spl_token_interface::native_mint::ID
pub const WSOL_MINT_ID: &str = "So11111111111111111111111111111111111111112";
/// Address Lookup Table program identifier. ("AddressLookupTab1e1111111111111111111111111").
/// @see solana_sdk_ids::address_lookup_table::ID
pub const ADDRESS_LOOKUP_TABLE_PROGRAM_ID: &str = "AddressLookupTab1e1111111111111111111111111";
/// BPF Loader program identifier. ("BPFLoader1111111111111111111111111111111111").
/// @see solana_sdk_ids::bpf_loader_deprecated::ID
pub const BPF_LOADER_DEPRECATED_PROGRAM_ID: &str = "BPFLoader1111111111111111111111111111111111";
/// BPF Loader program identifier. ("BPFLoaderUpgradeab1e11111111111111111111111").
/// @see solana_sdk_ids::bpf_loader_upgradeable::ID
pub const BPF_LOADER_UPGRADEABLE_PROGRAM_ID: &str = "BPFLoaderUpgradeab1e11111111111111111111111";
/// Compute Budget program identifier. ("ComputeBudget111111111111111111111111111111").
/// @see solana_sdk_ids::compute_budget::ID
pub const COMPUTE_BUDGET_PROGRAM_ID: &str = "ComputeBudget111111111111111111111111111111";
/// Config program identifier. ("Config1111111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::config::ID
pub const CONFIG_PROGRAM_ID: &str = "Config1111111111111111111111111111111111111";
/// ED25519 program identifier. ("Ed25519SigVerify111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::ed25519_program::ID
pub const ED25519_PROGRAM_ID: &str = "Ed25519SigVerify111111111111111111111111111";
/// Feature program identifier. ("Feature111111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::feature::ID
pub const FEATURE_PROGRAM_ID: &str = "Feature111111111111111111111111111111111111";
/// Incinerator program identifier. ("1nc1nerator11111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::incinerator::ID
pub const INCINERATOR_PROGRAM_ID: &str = "1nc1nerator11111111111111111111111111111111";
/// Loader V4 program identifier. ("LoaderV411111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::loader_v4::ID
pub const LOADER_V4_PROGRAM_ID: &str = "LoaderV411111111111111111111111111111111111";
/// Native Loader program identifier. ("NativeLoader1111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::native_loader::ID
pub const NATIVE_LOADER_PROGRAM_ID: &str = "NativeLoader1111111111111111111111111111111";
/// Secp256k1 program identifier. ("KeccakSecp256k11111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::secp256k1_program::ID
pub const SECP256K1_PROGRAM_ID: &str = "KeccakSecp256k11111111111111111111111111111";
/// Secp256r1 program identifier. ("Secp256r1SigVerify1111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::secp256r1_program::ID
pub const SECP256R1_PROGRAM_ID: &str = "Secp256r1SigVerify1111111111111111111111111";
/// Stake program identifier. ("Stake11111111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::stake::ID
pub const STAKE_PROGRAM_ID: &str = "Stake11111111111111111111111111111111111111";
/// Stake Config program identifier. ("StakeConfig11111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::stake::config::ID
pub const STAKE_CONFIG_PROGRAM_ID: &str = "StakeConfig11111111111111111111111111111111";
/// System program identifier. ("11111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::system_program::ID
pub const SYSTEM_PROGRAM_ID: &str = "11111111111111111111111111111111";
/// Compute Budget program identifier. ("ComputeBudget111111111111111111111111111111").
/// @see solana_sdk_ids::compute_budget::ID
pub const COMPUTE_BUDGET_PROGRAM_ID: &str = "ComputeBudget111111111111111111111111111111";
/// Vote program identifier. ("Vote111111111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::vote::ID
pub const VOTE_PROGRAM_ID: &str = "Vote111111111111111111111111111111111111111";
/// Sysvar program identifier. ("Sysvar1111111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::ID
pub const SYSVAR_PROGRAM_ID: &str = "Sysvar1111111111111111111111111111111111111";
/// Sysvar Clock program identifier. ("SysvarC1ock11111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::clock::ID
pub const SYSVAR_CLOCK_PROGRAM_ID: &str = "SysvarC1ock11111111111111111111111111111111";
/// Sysvar Epoch Rewards program identifier. ("SysvarEpochRewards1111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::epoch_rewards::ID
pub const SYSVAR_EPOCH_REWARDS_PROGRAM_ID: &str = "SysvarEpochRewards1111111111111111111111111";
/// Sysvar Epoch Schedule program identifier. ("SysvarEpochSchedu1e111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::epoch_schedule::ID
pub const SYSVAR_EPOCH_SCHEDULE_PROGRAM_ID: &str = "SysvarEpochSchedu1e111111111111111111111111";
/// Sysvar Fees program identifier. ("SysvarFees111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::fees::ID
pub const SYSVAR_FEES_PROGRAM_ID: &str = "SysvarFees111111111111111111111111111111111";
/// Sysvar Instructions program identifier. ("Sysvar1nstructions1111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::instructions::ID
pub const SYSVAR_INSTRUCTIONS_PROGRAM_ID: &str = "Sysvar1nstructions1111111111111111111111111";
/// Sysvar Last Restart Slot program identifier. ("SysvarLastRestartS1ot1111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::last_restart_slot::ID
pub const SYSVAR_LAST_RESTART_SLOT_PROGRAM_ID: &str = "SysvarLastRestartS1ot1111111111111111111111";
/// Sysvar Recent Blockhashes program identifier. ("SysvarRecentB1ockHashes11111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::recent_blockhashes::ID
pub const SYSVAR_RECENT_BLOCKHASHES_PROGRAM_ID: &str =
"SysvarRecentB1ockHashes11111111111111111111";
/// Sysvar Rent program identifier. ("SysvarRent111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::rent::ID
pub const SYSVAR_RENT_PROGRAM_ID: &str = "SysvarRent111111111111111111111111111111111";
/// Sysvar Rewards program identifier. ("SysvarRewards111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::rewards::ID
pub const SYSVAR_REWARDS_PROGRAM_ID: &str = "SysvarRewards111111111111111111111111111111";
/// Sysvar Slot Hashes program identifier. ("SysvarS1otHashes111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::slot_hashes::ID
pub const SYSVAR_SLOT_HASHES_PROGRAM_ID: &str = "SysvarS1otHashes111111111111111111111111111";
/// Sysvar Slot History program identifier. ("SysvarS1otHistory11111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::slot_history::ID
pub const SYSVAR_SLOT_HISTORY_PROGRAM_ID: &str = "SysvarS1otHistory11111111111111111111111111";
/// Sysvar Stake History program identifier. ("SysvarStakeHistory1111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::stake_history::ID
pub const SYSVAR_STAKE_HISTORY_PROGRAM_ID: &str = "SysvarStakeHistory1111111111111111111111111";
/// Zk Token Proof program identifier. ("ZkTokenProof1111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::zk_token_proof_program::ID
pub const ZK_TOKEN_PROOF_PROGRAM_ID: &str = "ZkTokenProof1111111111111111111111111111111";
/// Zk El Gamal Proof program identifier. ("ZkE1Gama1Proof11111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::zk_elgamal_proof_program::ID
pub const ZK_ELGAMAL_PROOF_PROGRAM_ID: &str = "ZkE1Gama1Proof11111111111111111111111111111";
/// Wrapped SOL mint identifier. ("So11111111111111111111111111111111111111112").
/// @see solana_sdk::pubkey::Pubkey = spl_token_interface::native_mint::ID
pub const WSOL_MINT_ID: &str = "So11111111111111111111111111111111111111112";
/// DexLab Swap/Pool program id. ("DSwpgjMvXhtGn6BsbqmacdBZyfLj6jSWf3HJpdJtmg6N").
pub const DEXLAB_PROGRAM_ID: &str = "DSwpgjMvXhtGn6BsbqmacdBZyfLj6jSWf3HJpdJtmg6N";
@@ -41,6 +158,11 @@ pub const METEORA_DAMM_V2_PROGRAM_ID: &str = "cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWA
/// Meteora DBC program id. ("dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN").
pub const METEORA_DBC_PROGRAM_ID: &str = "dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN";
/// Meteora DLMM program id. ("LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo").
///
/// DLMM = Dynamic Liquidity Market Maker.
pub const METEORA_DLMM_PROGRAM_ID: &str = "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo";
/// Orca Whirlpools program id. ("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc").
pub const ORCA_WHIRLPOOLS_PROGRAM_ID: &str = "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc";
@@ -58,3 +180,18 @@ pub const RAYDIUM_CLMM_PROGRAM_ID: &str = "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7gr
/// Raydium CPMM mainnet program id. ("CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C").
pub const RAYDIUM_CPMM_PROGRAM_ID: &str = "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C";
/// Raydium LaunchLab program id. ("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj").
pub const RAYDIUM_LAUNCHLAB_PROGRAM_ID: &str = "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj";
/// Raydium AMM routing program id. ("routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS").
pub const RAYDIUM_AMM_ROUTING_PROGRAM_ID: &str = "routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS";
/// Raydium Stable Swap AMM program id, deprecated. ("5quBtoiQqxF9Jv6KYKctB59NT3gtJD2Y65kdnB1Uev3h").
pub const RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID: &str = "5quBtoiQqxF9Jv6KYKctB59NT3gtJD2Y65kdnB1Uev3h";
/// Known Solana arbitrage/sandwich bot program id observed in local corpus.
///
/// This is not treated as a DEX program. It is used only to tag protocol
/// candidates with `candidate_surface = "arbitrage_bot"`.
pub const ARBITRAGE_BOT_6MWVT_PROGRAM_ID: &str = "6MWVTis8rmmk6Vt9zmAJJbmb3VuLpzoQ1aHH4N6wQEGh";

View File

@@ -50,11 +50,14 @@ pub use dtos::PoolDto;
pub use dtos::PoolListingDto;
pub use dtos::PoolOriginDto;
pub use dtos::PoolTokenDto;
pub use dtos::ProtocolCandidateDto;
pub use dtos::ProtocolCandidateSummaryDto;
pub use dtos::SwapDto;
pub use dtos::TokenBurnEventDto;
pub use dtos::TokenDto;
pub use dtos::TokenMintEventDto;
pub use dtos::TradeEventDto;
pub use dtos::TransactionClassificationDto;
pub use dtos::WalletDto;
pub use dtos::WalletHoldingDto;
pub use dtos::WalletParticipationDto;
@@ -82,11 +85,14 @@ pub use entities::PoolEntity;
pub use entities::PoolListingEntity;
pub use entities::PoolOriginEntity;
pub use entities::PoolTokenEntity;
pub use entities::ProtocolCandidateEntity;
pub use entities::ProtocolCandidateSummaryEntity;
pub use entities::SwapEntity;
pub use entities::TokenBurnEventEntity;
pub use entities::TokenEntity;
pub use entities::TokenMintEventEntity;
pub use entities::TradeEventEntity;
pub use entities::TransactionClassificationEntity;
pub use entities::WalletEntity;
pub use entities::WalletHoldingEntity;
pub use entities::WalletParticipationEntity;
@@ -172,6 +178,12 @@ pub use queries::query_pool_tokens_upsert;
pub use queries::query_pools_get_by_address;
pub use queries::query_pools_list;
pub use queries::query_pools_upsert;
pub use queries::query_protocol_candidate_summaries_list_by_priority;
pub use queries::query_protocol_candidates_delete_by_transaction_id;
pub use queries::query_protocol_candidates_insert;
pub use queries::query_protocol_candidates_list_by_program_id;
pub use queries::query_protocol_candidates_list_by_transaction_id;
pub use queries::query_protocol_candidates_list_recent;
pub use queries::query_swaps_list_recent;
pub use queries::query_swaps_upsert;
pub use queries::query_token_burn_events_list_recent;
@@ -187,6 +199,10 @@ pub use queries::query_trade_events_get_by_decoded_event_id;
pub use queries::query_trade_events_list_by_pair_id;
pub use queries::query_trade_events_list_by_transaction_id;
pub use queries::query_trade_events_upsert;
pub use queries::query_transaction_classifications_get_by_signature;
pub use queries::query_transaction_classifications_get_by_transaction_id;
pub use queries::query_transaction_classifications_list_recent;
pub use queries::query_transaction_classifications_upsert;
pub use queries::query_wallet_holdings_get_by_wallet_and_token;
pub use queries::query_wallet_holdings_list_by_wallet_id;
pub use queries::query_wallet_holdings_upsert;

View File

@@ -27,11 +27,14 @@ mod pool;
mod pool_listing;
mod pool_origin;
mod pool_token;
mod protocol_candidate;
mod protocol_candidate_summary;
mod swap;
mod token;
mod token_burn_event;
mod token_mint_event;
mod trade_event;
mod transaction_classification;
mod wallet;
mod wallet_holding;
mod wallet_participation;
@@ -82,11 +85,14 @@ pub use pool::PoolDto;
pub use pool_listing::PoolListingDto;
pub use pool_origin::PoolOriginDto;
pub use pool_token::PoolTokenDto;
pub use protocol_candidate::ProtocolCandidateDto;
pub use protocol_candidate_summary::ProtocolCandidateSummaryDto;
pub use swap::SwapDto;
pub use token::TokenDto;
pub use token_burn_event::TokenBurnEventDto;
pub use token_mint_event::TokenMintEventDto;
pub use trade_event::TradeEventDto;
pub use transaction_classification::TransactionClassificationDto;
pub use wallet::WalletDto;
pub use wallet_holding::WalletHoldingDto;
pub use wallet_participation::WalletParticipationDto;

View File

@@ -0,0 +1,114 @@
// file: kb_lib/src/db/dtos/protocol_candidate.rs
//! Protocol candidate DTO.
/// Application-facing protocol candidate DTO.
///
/// A protocol candidate records a program/instruction that should be inspected
/// later because it may correspond to an unsupported DEX, launch surface,
/// migration path or protocol-specific non-trade event.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProtocolCandidateDto {
/// Optional numeric primary key.
pub id: std::option::Option<i64>,
/// Related chain transaction id.
pub transaction_id: i64,
/// Optional related chain instruction id.
pub instruction_id: std::option::Option<i64>,
/// Transaction signature.
pub signature: std::string::String,
/// Optional Solana slot.
pub slot: std::option::Option<u64>,
/// Program id observed in the transaction instruction.
pub program_id: std::string::String,
/// Optional program name hint from parsed transaction data.
pub program_name_hint: std::option::Option<std::string::String>,
/// Optional candidate protocol code.
pub candidate_protocol: std::option::Option<std::string::String>,
/// Optional candidate surface code.
pub candidate_surface: std::option::Option<std::string::String>,
/// Human-readable reason.
pub reason: std::string::String,
/// Serialized JSON evidence.
pub evidence_json: std::string::String,
/// Creation timestamp.
pub created_at: chrono::DateTime<chrono::Utc>,
}
impl ProtocolCandidateDto {
/// Creates a protocol candidate DTO.
#[allow(clippy::too_many_arguments)]
pub fn new(
transaction_id: i64,
instruction_id: std::option::Option<i64>,
signature: std::string::String,
slot: std::option::Option<u64>,
program_id: std::string::String,
program_name_hint: std::option::Option<std::string::String>,
candidate_protocol: std::option::Option<std::string::String>,
candidate_surface: std::option::Option<std::string::String>,
reason: std::string::String,
evidence_json: std::string::String,
) -> Self {
return Self {
id: None,
transaction_id,
instruction_id,
signature,
slot,
program_id,
program_name_hint,
candidate_protocol,
candidate_surface,
reason,
evidence_json,
created_at: chrono::Utc::now(),
};
}
}
impl TryFrom<crate::ProtocolCandidateEntity> for ProtocolCandidateDto {
type Error = crate::Error;
fn try_from(entity: crate::ProtocolCandidateEntity) -> Result<Self, Self::Error> {
let slot = match entity.slot {
Some(slot) => {
let slot_result = u64::try_from(slot);
match slot_result {
Ok(slot) => Some(slot),
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot convert protocol candidate slot '{}' to u64: {}",
slot, error
)));
},
}
},
None => None,
};
let created_at_result = chrono::DateTime::parse_from_rfc3339(&entity.created_at);
let created_at = match created_at_result {
Ok(created_at) => created_at.with_timezone(&chrono::Utc),
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot parse protocol candidate created_at '{}': {}",
entity.created_at, error
)));
},
};
return Ok(Self {
id: Some(entity.id),
transaction_id: entity.transaction_id,
instruction_id: entity.instruction_id,
signature: entity.signature,
slot,
program_id: entity.program_id,
program_name_hint: entity.program_name_hint,
candidate_protocol: entity.candidate_protocol,
candidate_surface: entity.candidate_surface,
reason: entity.reason,
evidence_json: entity.evidence_json,
created_at,
});
}
}

View File

@@ -0,0 +1,96 @@
// file: kb_lib/src/db/dtos/protocol_candidate_summary.rs
//! Protocol candidate summary DTO.
/// Aggregated protocol candidate diagnostic row.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProtocolCandidateSummaryDto {
/// Program id observed in protocol candidates.
pub program_id: std::string::String,
/// Optional program name hint.
pub program_name_hint: std::option::Option<std::string::String>,
/// Optional candidate protocol.
pub candidate_protocol: std::option::Option<std::string::String>,
/// Optional candidate surface.
pub candidate_surface: std::option::Option<std::string::String>,
/// Candidate reason.
pub reason: std::string::String,
/// Number of candidate rows.
pub occurrence_count: u64,
/// Number of distinct transactions.
pub transaction_count: u64,
/// Latest observed slot.
pub last_slot: std::option::Option<u64>,
/// Latest candidate row id in this group.
pub latest_candidate_id: i64,
/// Latest signature in this group.
pub latest_signature: std::string::String,
/// Latest candidate creation timestamp.
pub latest_created_at: chrono::DateTime<chrono::Utc>,
}
impl TryFrom<crate::ProtocolCandidateSummaryEntity> for ProtocolCandidateSummaryDto {
type Error = crate::Error;
fn try_from(entity: crate::ProtocolCandidateSummaryEntity) -> Result<Self, Self::Error> {
let occurrence_count_result = u64::try_from(entity.occurrence_count);
let occurrence_count = match occurrence_count_result {
Ok(occurrence_count) => occurrence_count,
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot convert protocol candidate occurrence_count '{}' to u64: {}",
entity.occurrence_count, error
)));
},
};
let transaction_count_result = u64::try_from(entity.transaction_count);
let transaction_count = match transaction_count_result {
Ok(transaction_count) => transaction_count,
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot convert protocol candidate transaction_count '{}' to u64: {}",
entity.transaction_count, error
)));
},
};
let last_slot = match entity.last_slot {
Some(last_slot) => {
let slot_result = u64::try_from(last_slot);
match slot_result {
Ok(slot) => Some(slot),
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot convert protocol candidate last_slot '{}' to u64: {}",
last_slot, error
)));
},
}
},
None => None,
};
let latest_created_at_result =
chrono::DateTime::parse_from_rfc3339(entity.latest_created_at.as_str());
let latest_created_at = match latest_created_at_result {
Ok(latest_created_at) => latest_created_at.with_timezone(&chrono::Utc),
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot parse protocol candidate latest_created_at '{}': {}",
entity.latest_created_at, error
)));
},
};
return Ok(Self {
program_id: entity.program_id,
program_name_hint: entity.program_name_hint,
candidate_protocol: entity.candidate_protocol,
candidate_surface: entity.candidate_surface,
reason: entity.reason,
occurrence_count,
transaction_count,
last_slot,
latest_candidate_id: entity.latest_candidate_id,
latest_signature: entity.latest_signature,
latest_created_at,
});
}
}

View File

@@ -0,0 +1,130 @@
// file: kb_lib/src/db/dtos/transaction_classification.rs
//! Transaction classification DTO.
/// Application-facing transaction classification DTO.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TransactionClassificationDto {
/// Optional numeric primary key.
pub id: std::option::Option<i64>,
/// Related chain transaction id.
pub transaction_id: i64,
/// Transaction signature.
pub signature: std::string::String,
/// Optional Solana slot.
pub slot: std::option::Option<u64>,
/// Stable classification kind.
pub classification_kind: std::string::String,
/// Optional primary protocol name.
pub primary_protocol: std::option::Option<std::string::String>,
/// Optional primary program id.
pub primary_program_id: std::option::Option<std::string::String>,
/// Confidence level from 0 to 100.
pub confidence_level: i16,
/// Human-readable reason.
pub reason: std::string::String,
/// Serialized JSON evidence.
pub evidence_json: std::string::String,
/// Creation timestamp.
pub created_at: chrono::DateTime<chrono::Utc>,
/// Update timestamp.
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl TransactionClassificationDto {
/// Creates a new transaction classification DTO.
#[allow(clippy::too_many_arguments)]
pub fn new(
transaction_id: i64,
signature: std::string::String,
slot: std::option::Option<u64>,
classification_kind: std::string::String,
primary_protocol: std::option::Option<std::string::String>,
primary_program_id: std::option::Option<std::string::String>,
confidence_level: i16,
reason: std::string::String,
evidence_json: std::string::String,
) -> Self {
let now = chrono::Utc::now();
return Self {
id: None,
transaction_id,
signature,
slot,
classification_kind,
primary_protocol,
primary_program_id,
confidence_level,
reason,
evidence_json,
created_at: now,
updated_at: now,
};
}
}
impl TryFrom<crate::TransactionClassificationEntity> for TransactionClassificationDto {
type Error = crate::Error;
fn try_from(entity: crate::TransactionClassificationEntity) -> Result<Self, Self::Error> {
let slot = match entity.slot {
Some(slot) => {
let slot_result = u64::try_from(slot);
match slot_result {
Ok(slot) => Some(slot),
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot convert transaction classification slot '{}' to u64: {}",
slot, error
)));
},
}
},
None => None,
};
let created_at_result = chrono::DateTime::parse_from_rfc3339(&entity.created_at);
let created_at = match created_at_result {
Ok(created_at) => created_at.with_timezone(&chrono::Utc),
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot parse transaction classification created_at '{}': {}",
entity.created_at, error
)));
},
};
let updated_at_result = chrono::DateTime::parse_from_rfc3339(&entity.updated_at);
let updated_at = match updated_at_result {
Ok(updated_at) => updated_at.with_timezone(&chrono::Utc),
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot parse transaction classification updated_at '{}': {}",
entity.updated_at, error
)));
},
};
let confidence_level_result = i16::try_from(entity.confidence_level);
let confidence_level = match confidence_level_result {
Ok(confidence_level) => confidence_level,
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot convert transaction classification confidence_level '{}' to i16: {}",
entity.confidence_level, error
)));
},
};
return Ok(Self {
id: Some(entity.id),
transaction_id: entity.transaction_id,
signature: entity.signature,
slot,
classification_kind: entity.classification_kind,
primary_protocol: entity.primary_protocol,
primary_program_id: entity.primary_program_id,
confidence_level,
reason: entity.reason,
evidence_json: entity.evidence_json,
created_at,
updated_at,
});
}
}

View File

@@ -28,11 +28,14 @@ mod pool;
mod pool_listing;
mod pool_origin;
mod pool_token;
mod protocol_candidate;
mod protocol_candidate_summary;
mod swap;
mod token;
mod token_burn_event;
mod token_mint_event;
mod trade_event;
mod transaction_classification;
mod wallet;
mod wallet_holding;
mod wallet_participation;
@@ -61,11 +64,14 @@ pub use pool::PoolEntity;
pub use pool_listing::PoolListingEntity;
pub use pool_origin::PoolOriginEntity;
pub use pool_token::PoolTokenEntity;
pub use protocol_candidate::ProtocolCandidateEntity;
pub use protocol_candidate_summary::ProtocolCandidateSummaryEntity;
pub use swap::SwapEntity;
pub use token::TokenEntity;
pub use token_burn_event::TokenBurnEventEntity;
pub use token_mint_event::TokenMintEventEntity;
pub use trade_event::TradeEventEntity;
pub use transaction_classification::TransactionClassificationEntity;
pub use wallet::WalletEntity;
pub use wallet_holding::WalletHoldingEntity;
pub use wallet_participation::WalletParticipationEntity;

View File

@@ -0,0 +1,32 @@
// file: kb_lib/src/db/entities/protocol_candidate.rs
//! Protocol candidate entity.
/// Persisted protocol candidate row.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct ProtocolCandidateEntity {
/// Numeric primary key.
pub id: i64,
/// Related chain transaction id.
pub transaction_id: i64,
/// Optional related chain instruction id.
pub instruction_id: std::option::Option<i64>,
/// Transaction signature.
pub signature: std::string::String,
/// Optional Solana slot.
pub slot: std::option::Option<i64>,
/// Program id observed in the transaction instruction.
pub program_id: std::string::String,
/// Optional program name hint from parsed transaction data.
pub program_name_hint: std::option::Option<std::string::String>,
/// Optional candidate protocol code.
pub candidate_protocol: std::option::Option<std::string::String>,
/// Optional candidate surface code.
pub candidate_surface: std::option::Option<std::string::String>,
/// Human-readable reason.
pub reason: std::string::String,
/// Serialized JSON evidence.
pub evidence_json: std::string::String,
/// Creation timestamp encoded as RFC3339 UTC text.
pub created_at: std::string::String,
}

View File

@@ -0,0 +1,30 @@
// file: kb_lib/src/db/entities/protocol_candidate_summary.rs
//! Protocol candidate summary entity.
/// Aggregated protocol candidate diagnostic row.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct ProtocolCandidateSummaryEntity {
/// Program id observed in protocol candidates.
pub program_id: std::string::String,
/// Optional program name hint.
pub program_name_hint: std::option::Option<std::string::String>,
/// Optional candidate protocol.
pub candidate_protocol: std::option::Option<std::string::String>,
/// Optional candidate surface.
pub candidate_surface: std::option::Option<std::string::String>,
/// Candidate reason.
pub reason: std::string::String,
/// Number of candidate rows.
pub occurrence_count: i64,
/// Number of distinct transactions.
pub transaction_count: i64,
/// Latest observed slot.
pub last_slot: std::option::Option<i64>,
/// Latest candidate row id in this group.
pub latest_candidate_id: i64,
/// Latest signature in this group.
pub latest_signature: std::string::String,
/// Latest candidate creation timestamp encoded as RFC3339 UTC text.
pub latest_created_at: std::string::String,
}

View File

@@ -0,0 +1,32 @@
// file: kb_lib/src/db/entities/transaction_classification.rs
//! Transaction classification entity.
/// Persisted transaction classification row.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct TransactionClassificationEntity {
/// Numeric primary key.
pub id: i64,
/// Related chain transaction id.
pub transaction_id: i64,
/// Transaction signature.
pub signature: std::string::String,
/// Optional Solana slot.
pub slot: std::option::Option<i64>,
/// Stable classification kind.
pub classification_kind: std::string::String,
/// Optional primary protocol name.
pub primary_protocol: std::option::Option<std::string::String>,
/// Optional primary program id.
pub primary_program_id: std::option::Option<std::string::String>,
/// Confidence level from 0 to 100.
pub confidence_level: i64,
/// Human-readable reason.
pub reason: std::string::String,
/// Serialized JSON evidence.
pub evidence_json: std::string::String,
/// Creation timestamp encoded as RFC3339 UTC text.
pub created_at: std::string::String,
/// Update timestamp encoded as RFC3339 UTC text.
pub updated_at: std::string::String,
}

View File

@@ -27,11 +27,13 @@ mod pool;
mod pool_listing;
mod pool_origin;
mod pool_token;
mod protocol_candidate;
mod swap;
mod token;
mod token_burn_event;
mod token_mint_event;
mod trade_event;
mod transaction_classification;
mod wallet;
mod wallet_holding;
mod wallet_participation;
@@ -118,6 +120,12 @@ pub use pool_origin::query_pool_origins_list;
pub use pool_origin::query_pool_origins_upsert;
pub use pool_token::query_pool_tokens_list_by_pool_id;
pub use pool_token::query_pool_tokens_upsert;
pub use protocol_candidate::query_protocol_candidate_summaries_list_by_priority;
pub use protocol_candidate::query_protocol_candidates_delete_by_transaction_id;
pub use protocol_candidate::query_protocol_candidates_insert;
pub use protocol_candidate::query_protocol_candidates_list_by_program_id;
pub use protocol_candidate::query_protocol_candidates_list_by_transaction_id;
pub use protocol_candidate::query_protocol_candidates_list_recent;
pub use swap::query_swaps_list_recent;
pub use swap::query_swaps_upsert;
pub use token::query_tokens_get_by_id;
@@ -133,6 +141,10 @@ pub use trade_event::query_trade_events_get_by_decoded_event_id;
pub use trade_event::query_trade_events_list_by_pair_id;
pub use trade_event::query_trade_events_list_by_transaction_id;
pub use trade_event::query_trade_events_upsert;
pub use transaction_classification::query_transaction_classifications_get_by_signature;
pub use transaction_classification::query_transaction_classifications_get_by_transaction_id;
pub use transaction_classification::query_transaction_classifications_list_recent;
pub use transaction_classification::query_transaction_classifications_upsert;
pub use wallet::query_wallets_get_by_address;
pub use wallet::query_wallets_list;
pub use wallet::query_wallets_upsert;

View File

@@ -0,0 +1,337 @@
// file: kb_lib/src/db/queries/protocol_candidate.rs
//! Queries for `k_sol_protocol_candidates`.
/// Inserts one protocol candidate row.
pub async fn query_protocol_candidates_insert(
database: &crate::Database,
dto: &crate::ProtocolCandidateDto,
) -> Result<i64, crate::Error> {
let slot_i64 = match dto.slot {
Some(slot) => {
let slot_result = i64::try_from(slot);
match slot_result {
Ok(slot) => Some(slot),
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot convert protocol candidate slot '{}' to i64: {}",
slot, error
)));
},
}
},
None => None,
};
match database.connection() {
crate::DatabaseConnection::Sqlite(pool) => {
let query_result = sqlx::query(
r#"
INSERT INTO k_sol_protocol_candidates (
transaction_id,
instruction_id,
signature,
slot,
program_id,
program_name_hint,
candidate_protocol,
candidate_surface,
reason,
evidence_json,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(dto.transaction_id)
.bind(dto.instruction_id)
.bind(dto.signature.clone())
.bind(slot_i64)
.bind(dto.program_id.clone())
.bind(dto.program_name_hint.clone())
.bind(dto.candidate_protocol.clone())
.bind(dto.candidate_surface.clone())
.bind(dto.reason.clone())
.bind(dto.evidence_json.clone())
.bind(dto.created_at.to_rfc3339())
.execute(pool)
.await;
match query_result {
Ok(query_result) => return Ok(query_result.last_insert_rowid()),
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot insert k_sol_protocol_candidates on sqlite: {}",
error
)));
},
}
},
}
}
/// Deletes protocol candidates for one transaction.
///
/// This is useful before recomputing candidates for a replayed transaction.
pub async fn query_protocol_candidates_delete_by_transaction_id(
database: &crate::Database,
transaction_id: i64,
) -> Result<u64, crate::Error> {
match database.connection() {
crate::DatabaseConnection::Sqlite(pool) => {
let query_result = sqlx::query(
r#"
DELETE FROM k_sol_protocol_candidates
WHERE transaction_id = ?
"#,
)
.bind(transaction_id)
.execute(pool)
.await;
match query_result {
Ok(query_result) => return Ok(query_result.rows_affected()),
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot delete k_sol_protocol_candidates for transaction_id '{}' on sqlite: {}",
transaction_id, error
)));
},
}
},
}
}
/// Lists protocol candidates for one transaction.
pub async fn query_protocol_candidates_list_by_transaction_id(
database: &crate::Database,
transaction_id: i64,
) -> Result<std::vec::Vec<crate::ProtocolCandidateDto>, crate::Error> {
match database.connection() {
crate::DatabaseConnection::Sqlite(pool) => {
let query_result = sqlx::query_as::<sqlx::Sqlite, crate::ProtocolCandidateEntity>(
r#"
SELECT
id,
transaction_id,
instruction_id,
signature,
slot,
program_id,
program_name_hint,
candidate_protocol,
candidate_surface,
reason,
evidence_json,
created_at
FROM k_sol_protocol_candidates
WHERE transaction_id = ?
ORDER BY id ASC
"#,
)
.bind(transaction_id)
.fetch_all(pool)
.await;
let entities = match query_result {
Ok(entities) => entities,
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot list k_sol_protocol_candidates for transaction_id '{}' on sqlite: {}",
transaction_id, error
)));
},
};
return protocol_candidate_entities_to_dtos(entities);
},
}
}
/// Lists protocol candidates for one program id.
pub async fn query_protocol_candidates_list_by_program_id(
database: &crate::Database,
program_id: &str,
limit: u32,
) -> Result<std::vec::Vec<crate::ProtocolCandidateDto>, crate::Error> {
if limit == 0 {
return Ok(std::vec::Vec::new());
}
match database.connection() {
crate::DatabaseConnection::Sqlite(pool) => {
let query_result = sqlx::query_as::<sqlx::Sqlite, crate::ProtocolCandidateEntity>(
r#"
SELECT
id,
transaction_id,
instruction_id,
signature,
slot,
program_id,
program_name_hint,
candidate_protocol,
candidate_surface,
reason,
evidence_json,
created_at
FROM k_sol_protocol_candidates
WHERE program_id = ?
ORDER BY id DESC
LIMIT ?
"#,
)
.bind(program_id.to_string())
.bind(i64::from(limit))
.fetch_all(pool)
.await;
let entities = match query_result {
Ok(entities) => entities,
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot list k_sol_protocol_candidates for program_id '{}' on sqlite: {}",
program_id, error
)));
},
};
return protocol_candidate_entities_to_dtos(entities);
},
}
}
/// Lists recent protocol candidates ordered from newest to oldest.
pub async fn query_protocol_candidates_list_recent(
database: &crate::Database,
limit: u32,
) -> Result<std::vec::Vec<crate::ProtocolCandidateDto>, crate::Error> {
if limit == 0 {
return Ok(std::vec::Vec::new());
}
match database.connection() {
crate::DatabaseConnection::Sqlite(pool) => {
let query_result = sqlx::query_as::<sqlx::Sqlite, crate::ProtocolCandidateEntity>(
r#"
SELECT
id,
transaction_id,
instruction_id,
signature,
slot,
program_id,
program_name_hint,
candidate_protocol,
candidate_surface,
reason,
evidence_json,
created_at
FROM k_sol_protocol_candidates
ORDER BY id DESC
LIMIT ?
"#,
)
.bind(i64::from(limit))
.fetch_all(pool)
.await;
let entities = match query_result {
Ok(entities) => entities,
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot list recent k_sol_protocol_candidates on sqlite: {}",
error
)));
},
};
return protocol_candidate_entities_to_dtos(entities);
},
}
}
fn protocol_candidate_entities_to_dtos(
entities: std::vec::Vec<crate::ProtocolCandidateEntity>,
) -> Result<std::vec::Vec<crate::ProtocolCandidateDto>, crate::Error> {
let mut dtos = std::vec::Vec::new();
for entity in entities {
let dto_result = crate::ProtocolCandidateDto::try_from(entity);
let dto = match dto_result {
Ok(dto) => dto,
Err(error) => return Err(error),
};
dtos.push(dto);
}
return Ok(dtos);
}
/// Lists protocol candidate summaries ordered by investigation priority.
pub async fn query_protocol_candidate_summaries_list_by_priority(
database: &crate::Database,
limit: u32,
) -> Result<std::vec::Vec<crate::ProtocolCandidateSummaryDto>, crate::Error> {
if limit == 0 {
return Ok(std::vec::Vec::new());
}
match database.connection() {
crate::DatabaseConnection::Sqlite(pool) => {
let query_result =
sqlx::query_as::<sqlx::Sqlite, crate::ProtocolCandidateSummaryEntity>(
r#"
WITH grouped AS (
SELECT
program_id,
program_name_hint,
candidate_protocol,
candidate_surface,
reason,
COUNT(*) AS occurrence_count,
COUNT(DISTINCT signature) AS transaction_count,
MAX(slot) AS last_slot,
MAX(id) AS latest_candidate_id
FROM k_sol_protocol_candidates
GROUP BY
program_id,
program_name_hint,
candidate_protocol,
candidate_surface,
reason
)
SELECT
grouped.program_id,
grouped.program_name_hint,
grouped.candidate_protocol,
grouped.candidate_surface,
grouped.reason,
grouped.occurrence_count,
grouped.transaction_count,
grouped.last_slot,
grouped.latest_candidate_id,
latest.signature AS latest_signature,
latest.created_at AS latest_created_at
FROM grouped
JOIN k_sol_protocol_candidates latest
ON latest.id = grouped.latest_candidate_id
ORDER BY
grouped.transaction_count DESC,
grouped.occurrence_count DESC,
grouped.last_slot DESC,
grouped.latest_candidate_id DESC
LIMIT ?
"#,
)
.bind(i64::from(limit))
.fetch_all(pool)
.await;
let entities = match query_result {
Ok(entities) => entities,
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot list k_sol_protocol_candidates summaries on sqlite: {}",
error
)));
},
};
let mut dtos = std::vec::Vec::new();
for entity in entities {
let dto_result = crate::ProtocolCandidateSummaryDto::try_from(entity);
let dto = match dto_result {
Ok(dto) => dto,
Err(error) => return Err(error),
};
dtos.push(dto);
}
return Ok(dtos);
},
}
}

View File

@@ -0,0 +1,263 @@
// file: kb_lib/src/db/queries/transaction_classification.rs
//! Queries for `k_sol_transaction_classifications`.
/// Inserts or updates one transaction classification row.
pub async fn query_transaction_classifications_upsert(
database: &crate::Database,
dto: &crate::TransactionClassificationDto,
) -> Result<i64, crate::Error> {
let slot_i64 = match dto.slot {
Some(slot) => {
let slot_result = i64::try_from(slot);
match slot_result {
Ok(slot) => Some(slot),
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot convert transaction classification slot '{}' to i64: {}",
slot, error
)));
},
}
},
None => None,
};
match database.connection() {
crate::DatabaseConnection::Sqlite(pool) => {
let query_result = sqlx::query(
r#"
INSERT INTO k_sol_transaction_classifications (
transaction_id,
signature,
slot,
classification_kind,
primary_protocol,
primary_program_id,
confidence_level,
reason,
evidence_json,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(transaction_id) DO UPDATE SET
signature = excluded.signature,
slot = excluded.slot,
classification_kind = excluded.classification_kind,
primary_protocol = excluded.primary_protocol,
primary_program_id = excluded.primary_program_id,
confidence_level = excluded.confidence_level,
reason = excluded.reason,
evidence_json = excluded.evidence_json,
updated_at = excluded.updated_at
"#,
)
.bind(dto.transaction_id)
.bind(dto.signature.clone())
.bind(slot_i64)
.bind(dto.classification_kind.clone())
.bind(dto.primary_protocol.clone())
.bind(dto.primary_program_id.clone())
.bind(i64::from(dto.confidence_level))
.bind(dto.reason.clone())
.bind(dto.evidence_json.clone())
.bind(dto.created_at.to_rfc3339())
.bind(dto.updated_at.to_rfc3339())
.execute(pool)
.await;
if let Err(error) = query_result {
return Err(crate::Error::Db(format!(
"cannot upsert k_sol_transaction_classifications on sqlite: {}",
error
)));
}
let id_result = sqlx::query_scalar::<sqlx::Sqlite, i64>(
r#"
SELECT id
FROM k_sol_transaction_classifications
WHERE transaction_id = ?
LIMIT 1
"#,
)
.bind(dto.transaction_id)
.fetch_one(pool)
.await;
match id_result {
Ok(id) => return Ok(id),
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot fetch k_sol_transaction_classifications id for transaction_id '{}' on sqlite: {}",
dto.transaction_id, error
)));
},
}
},
}
}
/// Reads one transaction classification by transaction id.
pub async fn query_transaction_classifications_get_by_transaction_id(
database: &crate::Database,
transaction_id: i64,
) -> Result<std::option::Option<crate::TransactionClassificationDto>, crate::Error> {
match database.connection() {
crate::DatabaseConnection::Sqlite(pool) => {
let query_result =
sqlx::query_as::<sqlx::Sqlite, crate::TransactionClassificationEntity>(
r#"
SELECT
id,
transaction_id,
signature,
slot,
classification_kind,
primary_protocol,
primary_program_id,
confidence_level,
reason,
evidence_json,
created_at,
updated_at
FROM k_sol_transaction_classifications
WHERE transaction_id = ?
LIMIT 1
"#,
)
.bind(transaction_id)
.fetch_optional(pool)
.await;
let entity_option = match query_result {
Ok(entity_option) => entity_option,
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot fetch k_sol_transaction_classifications for transaction_id '{}' on sqlite: {}",
transaction_id, error
)));
},
};
match entity_option {
Some(entity) => {
let dto_result = crate::TransactionClassificationDto::try_from(entity);
match dto_result {
Ok(dto) => return Ok(Some(dto)),
Err(error) => return Err(error),
}
},
None => return Ok(None),
}
},
}
}
/// Reads one transaction classification by signature.
pub async fn query_transaction_classifications_get_by_signature(
database: &crate::Database,
signature: &str,
) -> Result<std::option::Option<crate::TransactionClassificationDto>, crate::Error> {
match database.connection() {
crate::DatabaseConnection::Sqlite(pool) => {
let query_result =
sqlx::query_as::<sqlx::Sqlite, crate::TransactionClassificationEntity>(
r#"
SELECT
id,
transaction_id,
signature,
slot,
classification_kind,
primary_protocol,
primary_program_id,
confidence_level,
reason,
evidence_json,
created_at,
updated_at
FROM k_sol_transaction_classifications
WHERE signature = ?
LIMIT 1
"#,
)
.bind(signature.to_string())
.fetch_optional(pool)
.await;
let entity_option = match query_result {
Ok(entity_option) => entity_option,
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot fetch k_sol_transaction_classifications for signature '{}' on sqlite: {}",
signature, error
)));
},
};
match entity_option {
Some(entity) => {
let dto_result = crate::TransactionClassificationDto::try_from(entity);
match dto_result {
Ok(dto) => return Ok(Some(dto)),
Err(error) => return Err(error),
}
},
None => return Ok(None),
}
},
}
}
/// Lists recent transaction classifications ordered from newest to oldest.
pub async fn query_transaction_classifications_list_recent(
database: &crate::Database,
limit: u32,
) -> Result<std::vec::Vec<crate::TransactionClassificationDto>, crate::Error> {
if limit == 0 {
return Ok(std::vec::Vec::new());
}
match database.connection() {
crate::DatabaseConnection::Sqlite(pool) => {
let query_result =
sqlx::query_as::<sqlx::Sqlite, crate::TransactionClassificationEntity>(
r#"
SELECT
id,
transaction_id,
signature,
slot,
classification_kind,
primary_protocol,
primary_program_id,
confidence_level,
reason,
evidence_json,
created_at,
updated_at
FROM k_sol_transaction_classifications
ORDER BY id DESC
LIMIT ?
"#,
)
.bind(i64::from(limit))
.fetch_all(pool)
.await;
let entities = match query_result {
Ok(entities) => entities,
Err(error) => {
return Err(crate::Error::Db(format!(
"cannot list k_sol_transaction_classifications on sqlite: {}",
error
)));
},
};
let mut dtos = std::vec::Vec::new();
for entity in entities {
let dto_result = crate::TransactionClassificationDto::try_from(entity);
let dto = match dto_result {
Ok(dto) => dto,
Err(error) => return Err(error),
};
dtos.push(dto);
}
return Ok(dtos);
},
}
}

View File

@@ -230,6 +230,94 @@ pub(crate) async fn ensure_schema(database: &crate::Database) -> Result<(), crat
if let Err(error) = result {
return Err(error);
}
let result = create_tbl_transaction_classifications(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_uix_transaction_classifications_transaction_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_idx_transaction_classifications_kind(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_tbl_protocol_candidates(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_idx_protocol_candidates_transaction_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_idx_protocol_candidates_program_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_tbl_pool_lifecycle_events(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_idx_pool_lifecycle_events_transaction_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_idx_pool_lifecycle_events_pool_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_uix_pool_lifecycle_events_decoded_event_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_tbl_fee_events(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_idx_fee_events_transaction_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_idx_fee_events_pool_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_uix_fee_events_decoded_event_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_tbl_reward_events(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_idx_reward_events_transaction_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_idx_reward_events_pool_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_uix_reward_events_decoded_event_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_tbl_pool_admin_events(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_idx_pool_admin_events_transaction_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_idx_pool_admin_events_pool_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_uix_pool_admin_events_decoded_event_id(pool).await;
if let Err(error) = result {
return Err(error);
}
let result = create_tbl_launch_surfaces(pool).await;
if let Err(error) = result {
return Err(error);
@@ -1878,3 +1966,413 @@ ON k_sol_pair_analytic_signals(pair_id)
)
.await;
}
/// Creates `k_sol_transaction_classifications`.
async fn create_tbl_transaction_classifications(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_tbl_transaction_classifications",
r#"
CREATE TABLE IF NOT EXISTS k_sol_transaction_classifications (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
transaction_id INTEGER NOT NULL,
signature TEXT NOT NULL,
slot INTEGER NULL,
classification_kind TEXT NOT NULL,
primary_protocol TEXT NULL,
primary_program_id TEXT NULL,
confidence_level INTEGER NOT NULL,
reason TEXT NOT NULL,
evidence_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"#,
)
.await;
}
/// Creates unique index on `k_sol_transaction_classifications(transaction_id)`.
async fn create_uix_transaction_classifications_transaction_id(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_uix_transaction_classifications_transaction_id",
r#"
CREATE UNIQUE INDEX IF NOT EXISTS uix_transaction_classifications_transaction_id
ON k_sol_transaction_classifications (transaction_id)
"#,
)
.await;
}
/// Creates index on `k_sol_transaction_classifications(classification_kind)`.
async fn create_idx_transaction_classifications_kind(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_idx_transaction_classifications_kind",
r#"
CREATE INDEX IF NOT EXISTS idx_transaction_classifications_kind
ON k_sol_transaction_classifications (classification_kind)
"#,
)
.await;
}
/// Creates `k_sol_protocol_candidates`.
async fn create_tbl_protocol_candidates(pool: &sqlx::SqlitePool) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_tbl_protocol_candidates",
r#"
CREATE TABLE IF NOT EXISTS k_sol_protocol_candidates (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
transaction_id INTEGER NOT NULL,
instruction_id INTEGER NULL,
signature TEXT NOT NULL,
slot INTEGER NULL,
program_id TEXT NOT NULL,
program_name_hint TEXT NULL,
candidate_protocol TEXT NULL,
candidate_surface TEXT NULL,
reason TEXT NOT NULL,
evidence_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
"#,
)
.await;
}
/// Creates index on `k_sol_protocol_candidates(transaction_id)`.
async fn create_idx_protocol_candidates_transaction_id(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_idx_protocol_candidates_transaction_id",
r#"
CREATE INDEX IF NOT EXISTS idx_protocol_candidates_transaction_id
ON k_sol_protocol_candidates (transaction_id)
"#,
)
.await;
}
/// Creates index on `k_sol_protocol_candidates(program_id)`.
async fn create_idx_protocol_candidates_program_id(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_idx_protocol_candidates_program_id",
r#"
CREATE INDEX IF NOT EXISTS idx_protocol_candidates_program_id
ON k_sol_protocol_candidates (program_id)
"#,
)
.await;
}
/// Creates `k_sol_pool_lifecycle_events`.
async fn create_tbl_pool_lifecycle_events(pool: &sqlx::SqlitePool) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_tbl_pool_lifecycle_events",
r#"
CREATE TABLE IF NOT EXISTS k_sol_pool_lifecycle_events (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
transaction_id INTEGER NOT NULL,
decoded_event_id INTEGER NULL,
dex_id INTEGER NULL,
pool_id INTEGER NULL,
pair_id INTEGER NULL,
signature TEXT NOT NULL,
slot INTEGER NULL,
protocol_name TEXT NOT NULL,
program_id TEXT NOT NULL,
event_kind TEXT NOT NULL,
pool_account TEXT NULL,
token_a_mint TEXT NULL,
token_b_mint TEXT NULL,
payload_json TEXT NOT NULL,
executed_at TEXT NOT NULL,
created_at TEXT NOT NULL
)
"#,
)
.await;
}
/// Creates index on `k_sol_pool_lifecycle_events(transaction_id)`.
async fn create_idx_pool_lifecycle_events_transaction_id(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_idx_pool_lifecycle_events_transaction_id",
r#"
CREATE INDEX IF NOT EXISTS idx_pool_lifecycle_events_transaction_id
ON k_sol_pool_lifecycle_events (transaction_id)
"#,
)
.await;
}
/// Creates index on `k_sol_pool_lifecycle_events(pool_id)`.
async fn create_idx_pool_lifecycle_events_pool_id(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_idx_pool_lifecycle_events_pool_id",
r#"
CREATE INDEX IF NOT EXISTS idx_pool_lifecycle_events_pool_id
ON k_sol_pool_lifecycle_events (pool_id)
"#,
)
.await;
}
/// Creates partial unique index on `k_sol_pool_lifecycle_events(decoded_event_id)`.
async fn create_uix_pool_lifecycle_events_decoded_event_id(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_uix_pool_lifecycle_events_decoded_event_id",
r#"
CREATE UNIQUE INDEX IF NOT EXISTS uix_pool_lifecycle_events_decoded_event_id
ON k_sol_pool_lifecycle_events (decoded_event_id)
WHERE decoded_event_id IS NOT NULL
"#,
)
.await;
}
/// Creates `k_sol_fee_events`.
async fn create_tbl_fee_events(pool: &sqlx::SqlitePool) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_tbl_fee_events",
r#"
CREATE TABLE IF NOT EXISTS k_sol_fee_events (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
transaction_id INTEGER NOT NULL,
decoded_event_id INTEGER NULL,
dex_id INTEGER NULL,
pool_id INTEGER NULL,
pair_id INTEGER NULL,
signature TEXT NOT NULL,
slot INTEGER NULL,
protocol_name TEXT NOT NULL,
program_id TEXT NOT NULL,
event_kind TEXT NOT NULL,
pool_account TEXT NULL,
actor_wallet TEXT NULL,
fee_token_mint TEXT NULL,
fee_amount_raw TEXT NULL,
payload_json TEXT NOT NULL,
executed_at TEXT NOT NULL,
created_at TEXT NOT NULL
)
"#,
)
.await;
}
/// Creates index on `k_sol_fee_events(transaction_id)`.
async fn create_idx_fee_events_transaction_id(pool: &sqlx::SqlitePool) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_idx_fee_events_transaction_id",
r#"
CREATE INDEX IF NOT EXISTS idx_fee_events_transaction_id
ON k_sol_fee_events (transaction_id)
"#,
)
.await;
}
/// Creates index on `k_sol_fee_events(pool_id)`.
async fn create_idx_fee_events_pool_id(pool: &sqlx::SqlitePool) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_idx_fee_events_pool_id",
r#"
CREATE INDEX IF NOT EXISTS idx_fee_events_pool_id
ON k_sol_fee_events (pool_id)
"#,
)
.await;
}
/// Creates partial unique index on `k_sol_fee_events(decoded_event_id)`.
async fn create_uix_fee_events_decoded_event_id(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_uix_fee_events_decoded_event_id",
r#"
CREATE UNIQUE INDEX IF NOT EXISTS uix_fee_events_decoded_event_id
ON k_sol_fee_events (decoded_event_id)
WHERE decoded_event_id IS NOT NULL
"#,
)
.await;
}
/// Creates `k_sol_reward_events`.
async fn create_tbl_reward_events(pool: &sqlx::SqlitePool) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_tbl_reward_events",
r#"
CREATE TABLE IF NOT EXISTS k_sol_reward_events (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
transaction_id INTEGER NOT NULL,
decoded_event_id INTEGER NULL,
dex_id INTEGER NULL,
pool_id INTEGER NULL,
pair_id INTEGER NULL,
signature TEXT NOT NULL,
slot INTEGER NULL,
protocol_name TEXT NOT NULL,
program_id TEXT NOT NULL,
event_kind TEXT NOT NULL,
pool_account TEXT NULL,
actor_wallet TEXT NULL,
reward_token_mint TEXT NULL,
reward_amount_raw TEXT NULL,
payload_json TEXT NOT NULL,
executed_at TEXT NOT NULL,
created_at TEXT NOT NULL
)
"#,
)
.await;
}
/// Creates index on `k_sol_reward_events(transaction_id)`.
async fn create_idx_reward_events_transaction_id(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_idx_reward_events_transaction_id",
r#"
CREATE INDEX IF NOT EXISTS idx_reward_events_transaction_id
ON k_sol_reward_events (transaction_id)
"#,
)
.await;
}
/// Creates index on `k_sol_reward_events(pool_id)`.
async fn create_idx_reward_events_pool_id(pool: &sqlx::SqlitePool) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_idx_reward_events_pool_id",
r#"
CREATE INDEX IF NOT EXISTS idx_reward_events_pool_id
ON k_sol_reward_events (pool_id)
"#,
)
.await;
}
/// Creates partial unique index on `k_sol_reward_events(decoded_event_id)`.
async fn create_uix_reward_events_decoded_event_id(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_uix_reward_events_decoded_event_id",
r#"
CREATE UNIQUE INDEX IF NOT EXISTS uix_reward_events_decoded_event_id
ON k_sol_reward_events (decoded_event_id)
WHERE decoded_event_id IS NOT NULL
"#,
)
.await;
}
/// Creates `k_sol_pool_admin_events`.
async fn create_tbl_pool_admin_events(pool: &sqlx::SqlitePool) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_tbl_pool_admin_events",
r#"
CREATE TABLE IF NOT EXISTS k_sol_pool_admin_events (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
transaction_id INTEGER NOT NULL,
decoded_event_id INTEGER NULL,
dex_id INTEGER NULL,
pool_id INTEGER NULL,
pair_id INTEGER NULL,
signature TEXT NOT NULL,
slot INTEGER NULL,
protocol_name TEXT NOT NULL,
program_id TEXT NOT NULL,
event_kind TEXT NOT NULL,
pool_account TEXT NULL,
actor_wallet TEXT NULL,
admin_action TEXT NULL,
payload_json TEXT NOT NULL,
executed_at TEXT NOT NULL,
created_at TEXT NOT NULL
)
"#,
)
.await;
}
/// Creates index on `k_sol_pool_admin_events(transaction_id)`.
async fn create_idx_pool_admin_events_transaction_id(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_idx_pool_admin_events_transaction_id",
r#"
CREATE INDEX IF NOT EXISTS idx_pool_admin_events_transaction_id
ON k_sol_pool_admin_events (transaction_id)
"#,
)
.await;
}
/// Creates index on `k_sol_pool_admin_events(pool_id)`.
async fn create_idx_pool_admin_events_pool_id(pool: &sqlx::SqlitePool) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_idx_pool_admin_events_pool_id",
r#"
CREATE INDEX IF NOT EXISTS idx_pool_admin_events_pool_id
ON k_sol_pool_admin_events (pool_id)
"#,
)
.await;
}
/// Creates partial unique index on `k_sol_pool_admin_events(decoded_event_id)`.
async fn create_uix_pool_admin_events_decoded_event_id(
pool: &sqlx::SqlitePool,
) -> Result<(), crate::Error> {
return execute_sqlite_schema_statement(
pool,
"create_uix_pool_admin_events_decoded_event_id",
r#"
CREATE UNIQUE INDEX IF NOT EXISTS uix_pool_admin_events_decoded_event_id
ON k_sol_pool_admin_events (decoded_event_id)
WHERE decoded_event_id IS NOT NULL
"#,
)
.await;
}

View File

@@ -155,8 +155,8 @@ impl SolanaWsDetectionService {
Some(token_program) => token_program,
None => return Ok(None),
};
if token_program != crate::SPL_TOKEN_PROGRAM_ID.to_string()
&& token_program != crate::SPL_TOKEN_2022_PROGRAM_ID.to_string()
if token_program.as_str() != crate::SPL_TOKEN_PROGRAM_ID
&& token_program.as_str() != crate::SPL_TOKEN_2022_PROGRAM_ID
{
return Ok(None);
}
@@ -181,7 +181,7 @@ impl SolanaWsDetectionService {
let slot =
extract_slot_from_result(notification.method.as_str(), &notification.params.result);
let payload = build_notification_payload(notification);
let is_quote_token = mint == crate::WSOL_MINT_ID.to_string();
let is_quote_token = mint.as_str() == crate::WSOL_MINT_ID;
let input = crate::DetectionTokenCandidateInput::new(
mint,
None,
@@ -230,8 +230,8 @@ impl SolanaWsDetectionService {
Some(owner) => owner,
None => return Ok(None),
};
if owner == crate::SPL_TOKEN_PROGRAM_ID.to_string()
|| owner == crate::SPL_TOKEN_2022_PROGRAM_ID.to_string()
if owner.as_str() == crate::SPL_TOKEN_PROGRAM_ID
|| owner.as_str() == crate::SPL_TOKEN_2022_PROGRAM_ID
{
return Ok(None);
}
@@ -603,10 +603,10 @@ fn build_signal_kind_for_notification(
}
let owner_option = extract_account_owner(account_value);
if let Some(owner) = owner_option {
if owner == crate::SPL_TOKEN_PROGRAM_ID.to_string() {
if owner.as_str() == crate::SPL_TOKEN_PROGRAM_ID {
return "signal.account_notification.spl_token".to_string();
}
if owner == crate::SPL_TOKEN_2022_PROGRAM_ID.to_string() {
if owner.as_str() == crate::SPL_TOKEN_2022_PROGRAM_ID {
return "signal.account_notification.spl_token_2022".to_string();
}
}
@@ -650,10 +650,10 @@ fn build_signal_kind_for_notification(
Some(owner) => owner,
None => return "signal.program_notification.generic".to_string(),
};
if owner == crate::SPL_TOKEN_PROGRAM_ID.to_string() {
if owner.as_str() == crate::SPL_TOKEN_PROGRAM_ID {
return "signal.program_notification.spl_token".to_string();
}
if owner == crate::SPL_TOKEN_2022_PROGRAM_ID.to_string() {
if owner.as_str() == crate::SPL_TOKEN_2022_PROGRAM_ID {
return "signal.program_notification.spl_token_2022".to_string();
}
return "signal.program_notification.generic".to_string();

295
kb_lib/src/dex_catalog.rs Normal file
View File

@@ -0,0 +1,295 @@
// file: kb_lib/src/dex_catalog.rs
//! Internal DEX catalog and persistence helpers.
//!
//! This module centralizes known DEX metadata used by detection,
//! decoding and future launch-surface integrations.
//!
//! It does not decode instructions and does not classify events.
/// Static metadata for one known DEX entry.
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub(crate) struct DexCatalogItem {
/// Stable internal DEX code.
pub(crate) code: &'static str,
/// Human-readable DEX name.
pub(crate) name: &'static str,
/// Main Solana program id.
pub(crate) program_id: std::option::Option<&'static str>,
/// Optional router program id.
pub(crate) router_program_id: std::option::Option<&'static str>,
/// Whether this DEX is currently enabled for detection.
pub(crate) is_enabled: bool,
}
/// Returns metadata for one known DEX or launch-backed swap surface.
pub(crate) fn dex_catalog_item_by_code(
code: &str,
) -> std::option::Option<crate::dex_catalog::DexCatalogItem> {
match code {
"raydium" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "raydium",
name: "Raydium AMM v4",
program_id: Some(crate::RAYDIUM_AMM_V4_PROGRAM_ID),
router_program_id: None,
is_enabled: true,
});
},
"raydium_cpmm" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "raydium_cpmm",
name: "Raydium CPMM",
program_id: Some(crate::RAYDIUM_CPMM_PROGRAM_ID),
router_program_id: None,
is_enabled: true,
});
},
"raydium_clmm" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "raydium_clmm",
name: "Raydium CLMM",
program_id: Some(crate::RAYDIUM_CLMM_PROGRAM_ID),
router_program_id: None,
is_enabled: true,
});
},
"pump_fun" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "pump_fun",
name: "Pump.fun",
program_id: Some(crate::PUMP_FUN_PROGRAM_ID),
router_program_id: None,
is_enabled: true,
});
},
"pump_swap" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "pump_swap",
name: "PumpSwap",
program_id: Some(crate::PUMP_SWAP_PROGRAM_ID),
router_program_id: None,
is_enabled: true,
});
},
"meteora_dbc" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "meteora_dbc",
name: "Meteora DBC",
program_id: Some(crate::METEORA_DBC_PROGRAM_ID),
router_program_id: None,
is_enabled: true,
});
},
"meteora_damm_v1" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "meteora_damm_v1",
name: "Meteora DAMM v1",
program_id: Some(crate::METEORA_DAMM_V1_PROGRAM_ID),
router_program_id: None,
is_enabled: true,
});
},
"meteora_damm_v2" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "meteora_damm_v2",
name: "Meteora DAMM v2",
program_id: Some(crate::METEORA_DAMM_V2_PROGRAM_ID),
router_program_id: None,
is_enabled: true,
});
},
"orca_whirlpools" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "orca_whirlpools",
name: "Orca Whirlpools",
program_id: Some(crate::ORCA_WHIRLPOOLS_PROGRAM_ID),
router_program_id: None,
is_enabled: true,
});
},
"fluxbeam" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "fluxbeam",
name: "FluxBeam",
program_id: Some(crate::FLUXBEAM_PROGRAM_ID),
router_program_id: None,
is_enabled: true,
});
},
"dexlab" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "dexlab",
name: "DexLab Swap/Pool",
program_id: Some(crate::DEXLAB_PROGRAM_ID),
router_program_id: None,
is_enabled: true,
});
},
// Planned launch/swap surfaces.
//
// These entries are intentionally present before decoder support so that
// the roadmap can evolve without duplicating DEX metadata later.
//
// Program ids should be filled only after validation against live
// transactions and official or otherwise trustworthy references.
"raydium_launchlab" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "raydium_launchlab",
name: "Raydium LaunchLab",
program_id: None,
router_program_id: None,
is_enabled: false,
});
},
"letsbonk" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "letsbonk",
name: "LetsBonk / Bonk.fun",
program_id: None,
router_program_id: None,
is_enabled: false,
});
},
"boop_fun" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "boop_fun",
name: "Boop.fun",
program_id: None,
router_program_id: None,
is_enabled: false,
});
},
"moonshot" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "moonshot",
name: "Moonshot",
program_id: None,
router_program_id: None,
is_enabled: false,
});
},
"believe" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "believe",
name: "Believe",
program_id: None,
router_program_id: None,
is_enabled: false,
});
},
"heaven" => {
return Some(crate::dex_catalog::DexCatalogItem {
code: "heaven",
name: "Heaven",
program_id: None,
router_program_id: None,
is_enabled: false,
});
},
_ => return None,
}
}
/// Ensures that one known DEX exists in storage and returns its internal id.
pub(crate) async fn ensure_known_dex(
database: &crate::Database,
code: &str,
) -> Result<i64, crate::Error> {
let dex_result = crate::query_dexs_get_by_code(database, code).await;
let dex_option = match dex_result {
Ok(dex_option) => dex_option,
Err(error) => return Err(error),
};
match dex_option {
Some(dex) => match dex.id {
Some(dex_id) => return Ok(dex_id),
None => {
return Err(crate::Error::InvalidState(format!("{} dex has no internal id", code)));
},
},
None => {
let catalog_item_option = crate::dex_catalog::dex_catalog_item_by_code(code);
let catalog_item = match catalog_item_option {
Some(catalog_item) => catalog_item,
None => {
return Err(crate::Error::InvalidState(format!(
"unknown dex catalog code '{}'",
code
)));
},
};
let program_id = match catalog_item.program_id {
Some(program_id) => Some(program_id.to_string()),
None => None,
};
let router_program_id = match catalog_item.router_program_id {
Some(router_program_id) => Some(router_program_id.to_string()),
None => None,
};
let dex_dto = crate::DexDto::new(
catalog_item.code.to_string(),
catalog_item.name.to_string(),
program_id,
router_program_id,
catalog_item.is_enabled,
);
return crate::query_dexs_upsert(database, &dex_dto).await;
},
}
}
#[cfg(test)]
mod tests {
#[test]
fn known_active_dexes_are_available_from_catalog() {
let codes = [
"raydium",
"raydium_cpmm",
"raydium_clmm",
"pump_fun",
"pump_swap",
"meteora_dbc",
"meteora_damm_v1",
"meteora_damm_v2",
"orca_whirlpools",
"fluxbeam",
"dexlab",
];
for code in codes {
let item_option = crate::dex_catalog::dex_catalog_item_by_code(code);
let item = match item_option {
Some(item) => item,
None => {
panic!("expected known dex catalog item for '{}'", code);
},
};
assert_eq!(item.code, code);
assert!(item.is_enabled);
assert!(item.program_id.is_some());
}
}
#[test]
fn planned_launch_surfaces_are_present_but_disabled() {
let codes = ["raydium_launchlab", "letsbonk", "boop_fun", "moonshot", "believe", "heaven"];
for code in codes {
let item_option = crate::dex_catalog::dex_catalog_item_by_code(code);
let item = match item_option {
Some(item) => item,
None => {
panic!("expected planned launch surface catalog item for '{}'", code);
},
};
assert_eq!(item.code, code);
assert!(!item.is_enabled);
}
}
#[test]
fn unknown_dex_code_is_not_silently_accepted() {
let item_option = crate::dex_catalog::dex_catalog_item_by_code("zora_solana");
assert!(item_option.is_none());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
// file: kb_lib/src/dex_decode_context.rs
//! Transaction context loading for DEX decoding.
//!
//! This module loads the persisted transaction and projected instructions
//! required by `DexDecodeService`.
/// Transaction context required by DEX decoding.
pub(crate) struct DexDecodeTransactionContext {
/// Persisted chain transaction.
pub(crate) transaction: crate::ChainTransactionDto,
/// Projected transaction instructions.
pub(crate) instructions: std::vec::Vec<crate::ChainInstructionDto>,
}
/// Loads the transaction and its projected instructions for DEX decoding.
pub(crate) async fn load_dex_decode_transaction_context(
database: &crate::Database,
signature: &str,
) -> Result<crate::dex_decode_context::DexDecodeTransactionContext, crate::Error> {
let transaction_result =
crate::query_chain_transactions_get_by_signature(database, signature).await;
let transaction_option = match transaction_result {
Ok(transaction_option) => transaction_option,
Err(error) => return Err(error),
};
let transaction = match transaction_option {
Some(transaction) => transaction,
None => {
return Err(crate::Error::InvalidState(format!(
"cannot decode unknown chain transaction '{}'",
signature
)));
},
};
let transaction_id = match transaction.id {
Some(transaction_id) => transaction_id,
None => {
return Err(crate::Error::InvalidState(format!(
"chain transaction '{}' has no internal id",
signature
)));
},
};
let instructions_result =
crate::query_chain_instructions_list_by_transaction_id(database, transaction_id).await;
let instructions = match instructions_result {
Ok(instructions) => instructions,
Err(error) => return Err(error),
};
return Ok(crate::dex_decode_context::DexDecodeTransactionContext {
transaction,
instructions,
});
}

View File

@@ -0,0 +1,140 @@
// file: kb_lib/src/dex_decoded_event_materialization.rs
//! Decoded DEX event materialization helpers.
//!
//! This module centralizes persistence of decoded DEX events:
//! payload enrichment, upsert, fetch-after-upsert, observation recording
//! and signal recording.
/// Input required to persist one decoded DEX event.
pub(crate) struct DexDecodedEventMaterializationInput<'a> {
/// Database connection.
pub(crate) database: &'a crate::Database,
/// Detection persistence service.
pub(crate) persistence: &'a crate::DetectionPersistenceService,
/// Parent transaction.
pub(crate) transaction: &'a crate::ChainTransactionDto,
/// Internal transaction id.
pub(crate) transaction_id: i64,
/// Optional internal instruction id.
pub(crate) instruction_id: std::option::Option<i64>,
/// Stable protocol name.
pub(crate) protocol_name: std::string::String,
/// Program id that produced the event.
pub(crate) program_id: std::string::String,
/// Stable decoded event kind.
pub(crate) event_kind: std::string::String,
/// Optional pool account.
pub(crate) pool_account: std::option::Option<std::string::String>,
/// Optional market account.
pub(crate) market_account: std::option::Option<std::string::String>,
/// Optional token A mint.
pub(crate) token_a_mint: std::option::Option<std::string::String>,
/// Optional token B mint.
pub(crate) token_b_mint: std::option::Option<std::string::String>,
/// Optional LP mint or protocol-specific secondary mint.
pub(crate) lp_mint: std::option::Option<std::string::String>,
/// Payload used for classification enrichment and DB storage.
pub(crate) enrichment_payload_json: serde_json::Value,
/// Payload recorded in the detection observation.
pub(crate) observation_payload_json: serde_json::Value,
/// Detection observation kind.
pub(crate) observation_kind: std::string::String,
/// Detection signal kind.
pub(crate) signal_kind: std::string::String,
/// Diagnostic message emitted when fetch-after-upsert fails.
pub(crate) missing_after_upsert_message: std::string::String,
}
/// Persists one decoded DEX event and records its first-seen observation/signal.
pub(crate) async fn materialize_dex_decoded_event(
input: crate::dex_decoded_event_materialization::DexDecodedEventMaterializationInput<'_>,
) -> Result<crate::DexDecodedEventDto, crate::Error> {
let payload_json_result = crate::enrich_and_serialize_dex_decoded_payload(
input.protocol_name.as_str(),
input.event_kind.as_str(),
input.enrichment_payload_json,
);
let payload_json = match payload_json_result {
Ok(payload_json) => payload_json,
Err(error) => return Err(error),
};
let existing_result = crate::query_dex_decoded_events_get_by_key(
input.database,
input.transaction_id,
input.instruction_id,
input.event_kind.as_str(),
)
.await;
let existing_option = match existing_result {
Ok(existing_option) => existing_option,
Err(error) => return Err(error),
};
let already_present = existing_option.is_some();
let dto = crate::DexDecodedEventDto::new(
input.transaction_id,
input.instruction_id,
input.protocol_name,
input.program_id,
input.event_kind.clone(),
input.pool_account,
input.market_account,
input.token_a_mint,
input.token_b_mint,
input.lp_mint,
payload_json,
);
let upsert_result = crate::query_dex_decoded_events_upsert(input.database, &dto).await;
if let Err(error) = upsert_result {
return Err(error);
}
let fetched_result = crate::query_dex_decoded_events_get_by_key(
input.database,
input.transaction_id,
input.instruction_id,
input.event_kind.as_str(),
)
.await;
let fetched_option = match fetched_result {
Ok(fetched_option) => fetched_option,
Err(error) => return Err(error),
};
let fetched = match fetched_option {
Some(fetched) => fetched,
None => {
return Err(crate::Error::InvalidState(input.missing_after_upsert_message));
},
};
if !already_present {
let observation_result = input
.persistence
.record_observation(&crate::DetectionObservationInput::new(
input.observation_kind,
crate::ObservationSourceKind::HttpRpc,
input.transaction.source_endpoint_name.clone(),
input.transaction.signature.clone(),
input.transaction.slot,
input.observation_payload_json.clone(),
))
.await;
let observation_id = match observation_result {
Ok(observation_id) => observation_id,
Err(error) => return Err(error),
};
let signal_result = input
.persistence
.record_signal(&crate::DetectionSignalInput::new(
input.signal_kind,
crate::AnalysisSignalSeverity::Low,
input.transaction.signature.clone(),
Some(observation_id),
None,
input.observation_payload_json,
))
.await;
if let Err(error) = signal_result {
return Err(error);
}
}
return Ok(fetched);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
// file: kb_lib/src/dex_detection_route.rs
//! Routing from decoded DEX events to business-level detection handlers.
/// Internal route selected for one decoded DEX event.
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub(crate) enum DexDetectionRoute {
/// Raydium AMM v4 initialize2 pool route.
RaydiumAmmV4Initialize2Pool,
/// Raydium CPMM trade route.
RaydiumCpmmTrade,
/// Raydium CLMM trade route.
RaydiumClmmTrade,
/// Pump.fun create token route.
PumpFunCreateV2Token,
/// Pump.fun trade route.
PumpFunTrade,
/// PumpSwap trade route.
PumpSwapTrade,
/// Incomplete PumpSwap event route.
SkipIncompletePumpSwapTrade,
/// Meteora DBC pool route.
MeteoraDbcPool,
/// Meteora DAMM v2 pool route.
MeteoraDammV2Pool,
/// Meteora DAMM v1 pool route.
MeteoraDammV1Pool,
/// Orca Whirlpools pool route.
OrcaWhirlpoolsPool,
/// FluxBeam pool route.
FluxbeamPool,
/// DexLab pool route.
DexlabPool,
}
/// Selects the business-level detection route for one decoded DEX event.
pub(crate) fn dex_detection_route(
decoded_event: &crate::DexDecodedEventDto,
) -> std::option::Option<crate::dex_detection_route::DexDetectionRoute> {
match (decoded_event.protocol_name.as_str(), decoded_event.event_kind.as_str()) {
("raydium_amm_v4", "raydium_amm_v4.initialize2_pool") => {
return Some(
crate::dex_detection_route::DexDetectionRoute::RaydiumAmmV4Initialize2Pool,
);
},
("raydium_cpmm", "raydium_cpmm.swap_base_input") => {
return Some(crate::dex_detection_route::DexDetectionRoute::RaydiumCpmmTrade);
},
("raydium_cpmm", "raydium_cpmm.swap_base_output") => {
return Some(crate::dex_detection_route::DexDetectionRoute::RaydiumCpmmTrade);
},
("raydium_clmm", "raydium_clmm.swap_v2") => {
return Some(crate::dex_detection_route::DexDetectionRoute::RaydiumClmmTrade);
},
("pump_fun", "pump_fun.create_v2_token") => {
return Some(crate::dex_detection_route::DexDetectionRoute::PumpFunCreateV2Token);
},
("pump_fun", "pump_fun.buy") => {
return Some(crate::dex_detection_route::DexDetectionRoute::PumpFunTrade);
},
("pump_fun", "pump_fun.sell") => {
return Some(crate::dex_detection_route::DexDetectionRoute::PumpFunTrade);
},
("pump_swap", "pump_swap.buy") => {
if crate::dex_detection_route::is_incomplete_pump_swap_decoded_event(decoded_event) {
return Some(
crate::dex_detection_route::DexDetectionRoute::SkipIncompletePumpSwapTrade,
);
}
return Some(crate::dex_detection_route::DexDetectionRoute::PumpSwapTrade);
},
("pump_swap", "pump_swap.sell") => {
if crate::dex_detection_route::is_incomplete_pump_swap_decoded_event(decoded_event) {
return Some(
crate::dex_detection_route::DexDetectionRoute::SkipIncompletePumpSwapTrade,
);
}
return Some(crate::dex_detection_route::DexDetectionRoute::PumpSwapTrade);
},
("meteora_dbc", "meteora_dbc.create_pool") => {
return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDbcPool);
},
("meteora_dbc", "meteora_dbc.swap") => {
return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDbcPool);
},
("meteora_damm_v2", "meteora_damm_v2.create_pool") => {
return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDammV2Pool);
},
("meteora_damm_v2", "meteora_damm_v2.swap") => {
return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDammV2Pool);
},
("meteora_damm_v1", "meteora_damm_v1.create_pool") => {
return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDammV1Pool);
},
("meteora_damm_v1", "meteora_damm_v1.swap") => {
return Some(crate::dex_detection_route::DexDetectionRoute::MeteoraDammV1Pool);
},
("orca_whirlpools", "orca_whirlpools.create_pool") => {
return Some(crate::dex_detection_route::DexDetectionRoute::OrcaWhirlpoolsPool);
},
("orca_whirlpools", "orca_whirlpools.swap") => {
return Some(crate::dex_detection_route::DexDetectionRoute::OrcaWhirlpoolsPool);
},
("fluxbeam", "fluxbeam.create_pool") => {
return Some(crate::dex_detection_route::DexDetectionRoute::FluxbeamPool);
},
("fluxbeam", "fluxbeam.swap") => {
return Some(crate::dex_detection_route::DexDetectionRoute::FluxbeamPool);
},
("dexlab", "dexlab.create_pool") => {
return Some(crate::dex_detection_route::DexDetectionRoute::DexlabPool);
},
("dexlab", "dexlab.swap") => {
return Some(crate::dex_detection_route::DexDetectionRoute::DexlabPool);
},
_ => return None,
}
}
fn is_incomplete_pump_swap_decoded_event(decoded_event: &crate::DexDecodedEventDto) -> bool {
if decoded_event.pool_account.is_none() {
return true;
}
if decoded_event.token_a_mint.is_none() {
return true;
}
if decoded_event.token_b_mint.is_none() {
return true;
}
return false;
}

View File

@@ -0,0 +1,509 @@
// 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",
}
}
}
/// 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();
}
/// 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(".increase_liquidity") {
return true;
}
if event_kind.contains(".decrease_liquidity") {
return true;
}
if event_kind.contains(".open_position") {
return true;
}
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 creation, initialization or migration events.
pub fn is_dex_pool_lifecycle_event_kind(event_kind: &str) -> bool {
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_v2_token") {
return true;
}
if event_kind.contains(".migrate") {
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 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 trade_candidate = is_dex_trade_event_kind(event_kind);
let candle_candidate = is_dex_candle_candidate_event_kind(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
},
};
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_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", 1);
if !trade_candidate {
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 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 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)));
}
#[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));
}
}

View File

@@ -0,0 +1,662 @@
// 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<std::string::String>,
/// Optional token A vault address, or base vault when `token_order` is `AlreadyBaseQuote`.
pub(crate) token_a_vault_address: std::option::Option<std::string::String>,
/// Optional token B vault address, or quote vault when `token_order` is `AlreadyBaseQuote`.
pub(crate) token_b_vault_address: std::option::Option<std::string::String>,
/// 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<std::string::String>,
}
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<std::string::String>,
token_b_vault_address: std::option::Option<std::string::String>,
source_endpoint_name: std::option::Option<std::string::String>,
) -> Result<Self, crate::Error> {
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<std::string::String>,
pool_kind: crate::PoolKind,
pool_status: crate::PoolStatus,
token_order: crate::dex_pool_materialization::DexPoolTokenOrder,
token_a_vault_address: std::option::Option<std::string::String>,
token_b_vault_address: std::option::Option<std::string::String>,
source_endpoint_name: std::option::Option<std::string::String>,
) -> Result<Self, crate::Error> {
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<i64, crate::Error> {
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<std::string::String, crate::Error> {
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<std::string::String, crate::Error> {
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<std::string::String, crate::Error> {
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<crate::DexPoolDetectionResult, crate::Error> {
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<std::string::String>,
quote_vault_address: std::option::Option<std::string::String>,
}
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<PoolMaterialization, crate::Error> {
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<PairMaterialization, crate::Error> {
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<ListingMaterialization, crate::Error> {
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<i64, crate::Error> {
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<i64>,
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<std::string::String> {
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<std::string::String> {
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()));
}
}

View File

@@ -23,10 +23,22 @@ mod db;
mod detect;
/// DEX-specific transaction decoders.
mod dex;
/// Internal known DEX catalog.
mod dex_catalog;
/// Persistence-oriented DEX decoding service.
mod dex_decode;
/// Transaction context loading for DEX decoding.
mod dex_decode_context;
/// Decoded DEX event materialization helpers.
mod dex_decoded_event_materialization;
/// Business-level detection built from decoded DEX events.
mod dex_detect;
/// Decoded DEX event to business-detection routing.
mod dex_detection_route;
/// Shared DEX event classification and decoded-payload enrichment helpers.
mod dex_event_classification;
/// Shared DEX pool materialization helpers.
mod dex_pool_materialization;
/// Shared error type for `kb_lib`.
mod error;
/// Generic asynchronous HTTP JSON-RPC client.
@@ -55,6 +67,8 @@ mod pair_candle_query;
mod pair_symbol;
/// Cross-DEX pool-origin recording service.
mod pool_origin;
/// Protocol candidate recording.
mod protocol_candidate_recording;
/// Typed Solana WebSocket PubSub helpers built on top of the generic JSON-RPC transport.
mod solana_pubsub_ws;
/// Historical token backfill service.
@@ -65,6 +79,22 @@ mod token_metadata;
mod tracing;
/// Cross-DEX trade aggregation service.
mod trade_aggregation;
/// Database context loading for trade aggregation.
mod trade_aggregation_context;
/// Trade amount resolution orchestration.
mod trade_amount_resolution;
/// Trade-event materialization.
mod trade_event_materialization;
/// Trade metric update and pricing helpers.
mod trade_metric_update;
/// PumpSwap trade amount recovery helpers.
mod trade_pump_swap_amounts;
/// Trade-side resolution helpers.
mod trade_side_resolution;
/// Solana transaction/meta trade amount extraction helpers.
mod trade_solana_amounts;
/// Transaction classification service.
mod transaction_classification;
/// Projection of resolved transactions into normalized internal DB tables.
mod tx_model;
/// Transaction resolution pipeline.
@@ -104,46 +134,143 @@ pub use config::SolanaConfig;
pub use config::SqliteDatabaseConfig;
/// WebSocket endpoint configuration.
pub use config::WsEndpointConfig;
/// Address Lookup Table program identifier. ("AddressLookupTab1e1111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::address_lookup_table::ID
pub use constants::ADDRESS_LOOKUP_TABLE_PROGRAM_ID;
/// Arbitrage Bot (6MWVT) / Arbitrage or Sandwich Bot. ("6MWVTis8rmmk6Vt9zmAJJbmb3VuLpzoQ1aHH4N6wQEGh").
pub use constants::ARBITRAGE_BOT_6MWVT_PROGRAM_ID;
/// Associated Token Account program identifier. ("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL").
/// @see solana_sdk::pubkey::Pubkey = spl_associated_token_account_interface::program::ID
pub use constants::ASSOCIATED_TOKEN_PROGRAM_ID;
/// BPF Loader program identifier. ("BPFLoader1111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::bpf_loader_deprecated::ID
pub use constants::BPF_LOADER_DEPRECATED_PROGRAM_ID;
/// BPF Loader program identifier. ("BPFLoaderUpgradeab1e11111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::bpf_loader_upgradeable::ID
pub use constants::BPF_LOADER_UPGRADEABLE_PROGRAM_ID;
/// Compute Budget program identifier. ("ComputeBudget111111111111111111111111111111").
/// @see solana_sdk_ids::compute_budget::ID
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::compute_budget::ID
pub use constants::COMPUTE_BUDGET_PROGRAM_ID;
/// Config program identifier. ("Config1111111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::config::ID
pub use constants::CONFIG_PROGRAM_ID;
/// DexLab Swap/Pool program id. ("DSwpgjMvXhtGn6BsbqmacdBZyfLj6jSWf3HJpdJtmg6N").
pub use constants::DEXLAB_PROGRAM_ID;
/// ED25519 program identifier. ("Ed25519SigVerify111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::ed25519_program::ID
pub use constants::ED25519_PROGRAM_ID;
/// Feature program identifier. ("Feature111111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::feature::ID
pub use constants::FEATURE_PROGRAM_ID;
/// FluxBeam program id. ("FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X").
pub use constants::FLUXBEAM_PROGRAM_ID;
/// Incinerator program identifier. ("1nc1nerator11111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::incinerator::ID
pub use constants::INCINERATOR_PROGRAM_ID;
/// Loader V4 program identifier. ("LoaderV411111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::loader_v4::ID
pub use constants::LOADER_V4_PROGRAM_ID;
/// Meteora DAMM v1 program id. ("Eo7WjKq67rjJQSZxS6z3YkapzY3eMj6Xy8X5EQVn5UaB").
pub use constants::METEORA_DAMM_V1_PROGRAM_ID;
/// Meteora DAMM v2 program id. ("cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG").
pub use constants::METEORA_DAMM_V2_PROGRAM_ID;
/// Meteora DBC program id. ("dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN").
pub use constants::METEORA_DBC_PROGRAM_ID;
/// Meteora DLMM program id. ("LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo").
pub use constants::METEORA_DLMM_PROGRAM_ID;
/// Native Loader program identifier. ("NativeLoader1111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::native_loader::ID
pub use constants::NATIVE_LOADER_PROGRAM_ID;
/// Orca Whirlpools program id. ("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc").
pub use constants::ORCA_WHIRLPOOLS_PROGRAM_ID;
/// Pump.fun program id. ("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P").
pub use constants::PUMP_FUN_PROGRAM_ID;
/// PumpSwap / PumpAMM program id. ("pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA").
pub use constants::PUMP_SWAP_PROGRAM_ID;
/// Raydium AMM routing program id. ("routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS").
pub use constants::RAYDIUM_AMM_ROUTING_PROGRAM_ID;
/// Raydium AmmV4 program id. ("675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8").
pub use constants::RAYDIUM_AMM_V4_PROGRAM_ID;
/// Raydium CLMM program id. ("CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK").
pub use constants::RAYDIUM_CLMM_PROGRAM_ID;
/// Raydium CPMM mainnet program id. ("CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C").
pub use constants::RAYDIUM_CPMM_PROGRAM_ID;
/// Raydium LaunchLab program id. ("LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj").
pub use constants::RAYDIUM_LAUNCHLAB_PROGRAM_ID;
/// Raydium Stable Swap AMM program id, deprecated. ("5quBtoiQqxF9Jv6KYKctB59NT3gtJD2Y65kdnB1Uev3h").
pub use constants::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID;
/// Secp256k1 program identifier. ("KeccakSecp256k11111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::secp256k1_program::ID
pub use constants::SECP256K1_PROGRAM_ID;
/// Secp256r1 program identifier. ("Secp256r1SigVerify1111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::secp256r1_program::ID
pub use constants::SECP256R1_PROGRAM_ID;
/// SPL Token-2022 program identifier. ("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb").
/// @see solana_sdk::pubkey::Pubkey = spl_token_2022_interface::ID
pub use constants::SPL_TOKEN_2022_PROGRAM_ID;
/// SPL Token program identifier. ("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA").
/// @see solana_sdk::pubkey::Pubkey = spl_token_interface::ID
pub use constants::SPL_TOKEN_PROGRAM_ID;
/// Stake Config program identifier. ("StakeConfig11111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::stake::config::ID
pub use constants::STAKE_CONFIG_PROGRAM_ID;
/// Stake program identifier. ("Stake11111111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::stake::ID
pub use constants::STAKE_PROGRAM_ID;
/// System program identifier. ("11111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::system_program::ID
pub use constants::SYSTEM_PROGRAM_ID;
/// Sysvar Clock program identifier. ("SysvarC1ock11111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::clock::ID
pub use constants::SYSVAR_CLOCK_PROGRAM_ID;
/// Sysvar Epoch Rewards program identifier. ("SysvarEpochRewards1111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::epoch_rewards::ID
pub use constants::SYSVAR_EPOCH_REWARDS_PROGRAM_ID;
/// Sysvar Epoch Schedule program identifier. ("SysvarEpochSchedu1e111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::epoch_schedule::ID
pub use constants::SYSVAR_EPOCH_SCHEDULE_PROGRAM_ID;
/// Sysvar Fees program identifier. ("SysvarFees111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::fees::ID
pub use constants::SYSVAR_FEES_PROGRAM_ID;
/// Sysvar Instructions program identifier. ("Sysvar1nstructions1111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::instructions::ID
pub use constants::SYSVAR_INSTRUCTIONS_PROGRAM_ID;
/// Sysvar Last Restart Slot program identifier. ("SysvarLastRestartS1ot1111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::last_restart_slot::ID
pub use constants::SYSVAR_LAST_RESTART_SLOT_PROGRAM_ID;
/// Sysvar program identifier. ("Sysvar1111111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::ID
pub use constants::SYSVAR_PROGRAM_ID;
/// Sysvar Recent Blockhashes program identifier. ("SysvarRecentB1ockHashes11111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::recent_blockhashes::ID
pub use constants::SYSVAR_RECENT_BLOCKHASHES_PROGRAM_ID;
/// Sysvar Rent program identifier. ("SysvarRent111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::rent::ID
pub use constants::SYSVAR_RENT_PROGRAM_ID;
/// Sysvar Rewards program identifier. ("SysvarRewards111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::rewards::ID
pub use constants::SYSVAR_REWARDS_PROGRAM_ID;
/// Sysvar Slot Hashes program identifier. ("SysvarS1otHashes111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::slot_hashes::ID
pub use constants::SYSVAR_SLOT_HASHES_PROGRAM_ID;
/// Sysvar Slot History program identifier. ("SysvarS1otHistory11111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::slot_history::ID
pub use constants::SYSVAR_SLOT_HISTORY_PROGRAM_ID;
/// Sysvar Stake History program identifier. ("SysvarStakeHistory1111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::sysvar::stake_history::ID
pub use constants::SYSVAR_STAKE_HISTORY_PROGRAM_ID;
/// Vote program identifier. ("Vote111111111111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::vote::ID
pub use constants::VOTE_PROGRAM_ID;
/// Wrapped SOL mint identifier. ("So11111111111111111111111111111111111111112").
/// @see solana_sdk::pubkey::Pubkey = spl_token_interface::native_mint::ID
pub use constants::WSOL_MINT_ID;
/// Zk El Gamal Proof program identifier. ("ZkE1Gama1Proof11111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::zk_elgamal_proof_program::ID
pub use constants::ZK_ELGAMAL_PROOF_PROGRAM_ID;
/// Zk Token Proof program identifier. ("ZkTokenProof1111111111111111111111111111111").
/// @see solana_sdk::pubkey::Pubkey = solana_sdk_ids::zk_token_proof_program::ID
pub use constants::ZK_TOKEN_PROOF_PROGRAM_ID;
/// Application-facing analysis signal DTO.
pub use db::AnalysisSignalDto;
/// Persisted analysis signal row.
@@ -284,6 +411,18 @@ pub use db::PoolTokenDto;
pub use db::PoolTokenEntity;
/// Role of one token inside a normalized pool.
pub use db::PoolTokenRole;
/// Application-facing protocol candidate DTO.
///
/// A protocol candidate records a program/instruction that should be inspected
/// later because it may correspond to an unsupported DEX, launch surface,
/// migration path or protocol-specific non-trade event.
pub use db::ProtocolCandidateDto;
/// Persisted protocol candidate row.
pub use db::ProtocolCandidateEntity;
/// Aggregated protocol candidate diagnostic row.
pub use db::ProtocolCandidateSummaryDto;
/// Aggregated protocol candidate diagnostic row.
pub use db::ProtocolCandidateSummaryEntity;
/// Application-facing normalized swap DTO.
pub use db::SwapDto;
/// Persisted normalized swap row.
@@ -306,6 +445,10 @@ pub use db::TokenMintEventEntity;
pub use db::TradeEventDto;
/// Persisted trade-event row.
pub use db::TradeEventEntity;
/// Application-facing transaction classification DTO.
pub use db::TransactionClassificationDto;
/// Persisted transaction classification row.
pub use db::TransactionClassificationEntity;
/// Application-facing wallet DTO.
pub use db::WalletDto;
/// Persisted wallet row.
@@ -482,6 +625,20 @@ pub use db::query_pools_get_by_address;
pub use db::query_pools_list;
/// Inserts or updates one normalized pool row by address.
pub use db::query_pools_upsert;
/// Lists protocol candidate summaries ordered by investigation priority.
pub use db::query_protocol_candidate_summaries_list_by_priority;
/// Deletes protocol candidates for one transaction.
///
/// This is useful before recomputing candidates for a replayed transaction.
pub use db::query_protocol_candidates_delete_by_transaction_id;
/// Inserts one protocol candidate row.
pub use db::query_protocol_candidates_insert;
/// Lists protocol candidates for one program id.
pub use db::query_protocol_candidates_list_by_program_id;
/// Lists protocol candidates for one transaction.
pub use db::query_protocol_candidates_list_by_transaction_id;
/// Lists recent protocol candidates ordered from newest to oldest.
pub use db::query_protocol_candidates_list_recent;
/// Lists recent swaps ordered from newest to oldest.
pub use db::query_swaps_list_recent;
/// Inserts or updates one normalized swap row.
@@ -512,6 +669,14 @@ pub use db::query_trade_events_list_by_pair_id;
pub use db::query_trade_events_list_by_transaction_id;
/// Inserts or updates one trade-event row and returns its stable internal id.
pub use db::query_trade_events_upsert;
/// Reads one transaction classification by signature.
pub use db::query_transaction_classifications_get_by_signature;
/// Reads one transaction classification by transaction id.
pub use db::query_transaction_classifications_get_by_transaction_id;
/// Lists recent transaction classifications ordered from newest to oldest.
pub use db::query_transaction_classifications_list_recent;
/// Inserts or updates one transaction classification row.
pub use db::query_transaction_classifications_upsert;
/// Returns one wallet-holding row identified by `(wallet_id, token_id)`, if it exists.
pub use db::query_wallet_holdings_get_by_wallet_and_token;
/// Lists wallet-holding rows for one wallet id.
@@ -644,6 +809,36 @@ pub use dex_decode::DexDecodeService;
pub use dex_detect::DexDetectService;
/// Result of one business-level DEX pool detection.
pub use dex_detect::DexPoolDetectionResult;
/// Stable DEX event business category.
pub use dex_event_classification::DexEventCategory;
/// Classifies a DEX event kind into a stable category.
pub use dex_event_classification::classify_dex_event_category;
/// Classifies a DEX event kind and returns its persisted category code.
pub use dex_event_classification::classify_dex_event_category_code;
/// Enriches and serializes a decoded DEX payload.
pub use dex_event_classification::enrich_and_serialize_dex_decoded_payload;
/// Enriches a decoded DEX payload with classification metadata.
pub use dex_event_classification::enrich_dex_decoded_payload;
/// Parses, enriches and serializes a decoded DEX payload.
pub use dex_event_classification::enrich_serialized_dex_decoded_payload;
/// Returns true when a decoded payload is a candle candidate.
pub use dex_event_classification::is_decoded_event_candle_candidate;
/// Returns true when a decoded payload is a trade candidate.
pub use dex_event_classification::is_decoded_event_trade_candidate;
/// Returns true for admin/config/permission DEX events.
pub use dex_event_classification::is_dex_admin_event_kind;
/// Returns true when a DEX event kind can directly feed candle materialization.
pub use dex_event_classification::is_dex_candle_candidate_event_kind;
/// Returns true for fee collection DEX events.
pub use dex_event_classification::is_dex_fee_event_kind;
/// Returns true for liquidity lifecycle DEX events.
pub use dex_event_classification::is_dex_liquidity_event_kind;
/// Returns true for pool lifecycle DEX events.
pub use dex_event_classification::is_dex_pool_lifecycle_event_kind;
/// Returns true for reward or emission DEX events.
pub use dex_event_classification::is_dex_reward_event_kind;
/// Returns true for swap-like DEX events.
pub use dex_event_classification::is_dex_trade_event_kind;
/// Global error type used by the `kb_lib` crate.
///
/// The project intentionally avoids `anyhow` and `thiserror`, so this
@@ -793,6 +988,8 @@ pub use tracing::init_tracing;
pub use trade_aggregation::TradeAggregationResult;
/// Trade-aggregation service.
pub use trade_aggregation::TradeAggregationService;
/// Service used to classify projected Solana transactions.
pub use transaction_classification::TransactionClassificationService;
/// Service projecting resolved transaction JSON into internal chain tables.
pub use tx_model::TransactionModelService;
/// Result of one transaction resolution pass.

View File

@@ -63,6 +63,10 @@ pub struct LocalPipelineReplayResult {
/// This is a replay write/result counter, not the number of distinct rows
/// currently persisted in the analytic signal table.
pub analytic_signal_upsert_count: usize,
/// Total transaction classification rows upserted during replay.
pub transaction_classification_count: usize,
/// Number of transactions that produced a classification error.
pub transaction_classification_error_count: usize,
/// Number of token metadata rows updated after replay.
pub token_metadata_updated_count: usize,
/// Number of pair symbols updated after replay.
@@ -122,6 +126,8 @@ impl LocalPipelineReplayService {
let pair_candle_aggregation =
crate::PairCandleAggregationService::new(self.database.clone());
let pair_analytic_signal = crate::PairAnalyticSignalService::new(self.database.clone());
let transaction_classification =
crate::TransactionClassificationService::new(self.database.clone());
let mut result = LocalPipelineReplayResult {
selected_transaction_count: signatures.len(),
..Default::default()
@@ -209,6 +215,22 @@ impl LocalPipelineReplayService {
);
},
}
let classification_result = transaction_classification
.classify_transaction_by_signature(signature.as_str())
.await;
match classification_result {
Ok(_) => {
result.transaction_classification_count += 1;
},
Err(error) => {
result.transaction_classification_error_count += 1;
tracing::warn!(
signature = %signature,
error = %error,
"local pipeline replay transaction classification step failed"
);
},
}
result.replayed_transaction_count += 1;
}
if config.refresh_missing_token_metadata {

View File

@@ -0,0 +1,530 @@
// file: kb_lib/src/protocol_candidate_recording.rs
//! Protocol candidate recording.
//!
//! This module records candidate protocol/program instructions for transactions
//! that were not fully decoded by the current DEX decoders.
/// Input used to record protocol candidates for one classified transaction.
pub(crate) struct ProtocolCandidateRecordingInput<'a> {
/// Database connection.
pub(crate) database: &'a crate::Database,
/// Persisted transaction.
pub(crate) transaction: &'a crate::ChainTransactionDto,
/// Internal transaction id.
pub(crate) transaction_id: i64,
/// Projected instructions for the transaction.
pub(crate) instructions: &'a [crate::ChainInstructionDto],
/// Persisted classification kind.
pub(crate) classification_kind: &'a str,
}
/// Records protocol candidates for one classified transaction.
///
/// Existing candidates for the same transaction are deleted first so replay is
/// deterministic.
pub(crate) async fn record_protocol_candidates_for_classification(
input: crate::protocol_candidate_recording::ProtocolCandidateRecordingInput<'_>,
) -> Result<usize, crate::Error> {
let delete_result = crate::query_protocol_candidates_delete_by_transaction_id(
input.database,
input.transaction_id,
)
.await;
if let Err(error) = delete_result {
return Err(error);
}
let candidate_specs =
crate::protocol_candidate_recording::build_protocol_candidate_specs_for_classification(
input.transaction,
input.transaction_id,
input.instructions,
input.classification_kind,
);
let mut inserted_count = 0_usize;
for candidate_spec in candidate_specs {
let dto = crate::ProtocolCandidateDto::new(
input.transaction_id,
candidate_spec.instruction_id,
input.transaction.signature.clone(),
input.transaction.slot,
candidate_spec.program_id,
candidate_spec.program_name_hint,
candidate_spec.candidate_protocol,
candidate_spec.candidate_surface,
candidate_spec.reason,
candidate_spec.evidence_json,
);
let insert_result = crate::query_protocol_candidates_insert(input.database, &dto).await;
match insert_result {
Ok(_) => {
inserted_count += 1;
},
Err(error) => return Err(error),
}
}
return Ok(inserted_count);
}
struct ProtocolCandidateSpec {
instruction_id: std::option::Option<i64>,
program_id: std::string::String,
program_name_hint: std::option::Option<std::string::String>,
candidate_protocol: std::option::Option<std::string::String>,
candidate_surface: std::option::Option<std::string::String>,
reason: std::string::String,
evidence_json: std::string::String,
}
fn build_protocol_candidate_specs_for_classification(
transaction: &crate::ChainTransactionDto,
transaction_id: i64,
instructions: &[crate::ChainInstructionDto],
classification_kind: &str,
) -> std::vec::Vec<ProtocolCandidateSpec> {
if classification_kind == "known_dex_program_unclassified" {
return build_known_dex_program_candidate_specs(transaction, transaction_id, instructions);
}
if classification_kind == "unknown_or_unclassified" {
return build_unknown_program_candidate_specs(transaction, transaction_id, instructions);
}
return std::vec::Vec::new();
}
fn build_known_dex_program_candidate_specs(
transaction: &crate::ChainTransactionDto,
transaction_id: i64,
instructions: &[crate::ChainInstructionDto],
) -> std::vec::Vec<ProtocolCandidateSpec> {
let mut specs = std::vec::Vec::new();
for instruction in instructions {
let program_id = match instruction.program_id.clone() {
Some(program_id) => program_id,
None => continue,
};
let known_protocol = known_dex_protocol_name(program_id.as_str());
let known_protocol = match known_protocol {
Some(known_protocol) => known_protocol,
None => continue,
};
let evidence_json = build_instruction_evidence_json(
transaction,
transaction_id,
instruction,
"known_dex_program_without_decoded_event",
);
specs.push(ProtocolCandidateSpec {
instruction_id: instruction.id,
program_id,
program_name_hint: instruction.program_name.clone(),
candidate_protocol: Some(known_protocol.to_string()),
candidate_surface: None,
reason: "known DEX program instruction did not produce a decoded DEX event".to_string(),
evidence_json,
});
}
return specs;
}
fn build_unknown_program_candidate_specs(
transaction: &crate::ChainTransactionDto,
transaction_id: i64,
instructions: &[crate::ChainInstructionDto],
) -> std::vec::Vec<ProtocolCandidateSpec> {
let mut specs = std::vec::Vec::new();
for instruction in instructions {
let program_id = match instruction.program_id.clone() {
Some(program_id) => program_id,
None => continue,
};
if should_ignore_program_id(program_id.as_str()) {
continue;
}
let surface_hint = infer_candidate_surface(program_id.as_str(), instruction);
let evidence_json = build_instruction_evidence_json(
transaction,
transaction_id,
instruction,
"unknown_or_unclassified_program_instruction",
);
specs.push(ProtocolCandidateSpec {
instruction_id: instruction.id,
program_id,
program_name_hint: instruction.program_name.clone(),
candidate_protocol: None,
candidate_surface: surface_hint,
reason: "transaction has no decoded DEX event and includes a non-ignored program instruction".to_string(),
evidence_json,
});
}
return specs;
}
fn build_instruction_evidence_json(
transaction: &crate::ChainTransactionDto,
transaction_id: i64,
instruction: &crate::ChainInstructionDto,
reason_code: &str,
) -> std::string::String {
let evidence_value = serde_json::json!({
"reasonCode": reason_code,
"transactionId": transaction_id,
"signature": transaction.signature,
"slot": transaction.slot,
"instructionId": instruction.id,
"parentInstructionId": instruction.parent_instruction_id,
"instructionIndex": instruction.instruction_index,
"programId": instruction.program_id,
"programName": instruction.program_name,
"stackHeight": instruction.stack_height,
"parsedType": instruction.parsed_type
});
let evidence_json_result = serde_json::to_string(&evidence_value);
match evidence_json_result {
Ok(evidence_json) => return evidence_json,
Err(error) => {
return format!(
"{{\"reasonCode\":\"evidence_serialization_failed\",\"error\":\"{}\"}}",
error
);
},
}
}
fn known_dex_protocol_name(program_id: &str) -> std::option::Option<&'static str> {
if program_id == crate::RAYDIUM_AMM_V4_PROGRAM_ID {
return Some("raydium_amm_v4");
}
if program_id == crate::RAYDIUM_CPMM_PROGRAM_ID {
return Some("raydium_cpmm");
}
if program_id == crate::RAYDIUM_CLMM_PROGRAM_ID {
return Some("raydium_clmm");
}
if program_id == crate::RAYDIUM_LAUNCHLAB_PROGRAM_ID {
return Some("raydium_launchlab");
}
if program_id == crate::RAYDIUM_AMM_ROUTING_PROGRAM_ID {
return Some("raydium_router");
}
if program_id == crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID {
return Some("raydium_stable_swap");
}
if program_id == crate::PUMP_FUN_PROGRAM_ID {
return Some("pump_fun");
}
if program_id == crate::PUMP_SWAP_PROGRAM_ID {
return Some("pump_swap");
}
if program_id == crate::METEORA_DBC_PROGRAM_ID {
return Some("meteora_dbc");
}
if program_id == crate::METEORA_DLMM_PROGRAM_ID {
return Some("meteora_dlmm");
}
if program_id == crate::METEORA_DAMM_V1_PROGRAM_ID {
return Some("meteora_damm_v1");
}
if program_id == crate::METEORA_DAMM_V2_PROGRAM_ID {
return Some("meteora_damm_v2");
}
if program_id == crate::ORCA_WHIRLPOOLS_PROGRAM_ID {
return Some("orca_whirlpools");
}
if program_id == crate::FLUXBEAM_PROGRAM_ID {
return Some("fluxbeam");
}
if program_id == crate::DEXLAB_PROGRAM_ID {
return Some("dexlab");
}
return None;
}
fn should_ignore_program_id(program_id: &str) -> bool {
if program_id == crate::SYSTEM_PROGRAM_ID {
return true;
}
if program_id == crate::SPL_TOKEN_PROGRAM_ID {
return true;
}
if program_id == crate::SPL_TOKEN_2022_PROGRAM_ID {
return true;
}
if program_id == crate::ASSOCIATED_TOKEN_PROGRAM_ID {
return true;
}
if program_id == crate::COMPUTE_BUDGET_PROGRAM_ID {
return true;
}
if program_id == crate::ADDRESS_LOOKUP_TABLE_PROGRAM_ID {
return true;
}
if program_id == crate::BPF_LOADER_DEPRECATED_PROGRAM_ID {
return true;
}
if program_id == crate::BPF_LOADER_UPGRADEABLE_PROGRAM_ID {
return true;
}
if program_id == crate::LOADER_V4_PROGRAM_ID {
return true;
}
if program_id == crate::NATIVE_LOADER_PROGRAM_ID {
return true;
}
if program_id == crate::CONFIG_PROGRAM_ID {
return true;
}
if program_id == crate::VOTE_PROGRAM_ID {
return true;
}
if program_id == crate::STAKE_PROGRAM_ID {
return true;
}
if program_id == crate::STAKE_CONFIG_PROGRAM_ID {
return true;
}
if program_id == crate::ED25519_PROGRAM_ID {
return true;
}
if program_id == crate::SECP256K1_PROGRAM_ID {
return true;
}
if program_id == crate::SECP256R1_PROGRAM_ID {
return true;
}
if program_id == crate::ZK_TOKEN_PROOF_PROGRAM_ID {
return true;
}
if program_id == crate::ZK_ELGAMAL_PROOF_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_CLOCK_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_RENT_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_INSTRUCTIONS_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_EPOCH_REWARDS_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_EPOCH_SCHEDULE_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_FEES_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_LAST_RESTART_SLOT_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_RECENT_BLOCKHASHES_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_REWARDS_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_SLOT_HASHES_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_SLOT_HISTORY_PROGRAM_ID {
return true;
}
if program_id == crate::SYSVAR_STAKE_HISTORY_PROGRAM_ID {
return true;
}
return false;
}
fn infer_candidate_surface(
program_id: &str,
instruction: &crate::ChainInstructionDto,
) -> std::option::Option<std::string::String> {
if program_id == crate::ARBITRAGE_BOT_6MWVT_PROGRAM_ID {
return Some("arbitrage_bot".to_string());
}
if is_known_launch_surface_program_id(program_id) {
return Some("launch_surface".to_string());
}
if let Some(program_name) = instruction.program_name.as_deref() {
let normalized = program_name.to_ascii_lowercase();
if normalized.contains("arbitrage") {
return Some("arbitrage_bot".to_string());
}
if normalized.contains("sandwich") {
return Some("arbitrage_bot".to_string());
}
if normalized.contains("launch") {
return Some("launch_surface".to_string());
}
if normalized.contains("meteora") {
return Some("meteora_related".to_string());
}
if normalized.contains("raydium") {
return Some("raydium_related".to_string());
}
if normalized.contains("pump") {
return Some("pump_related".to_string());
}
if normalized.contains("swap") {
return Some("swap_related".to_string());
}
}
return None;
}
fn is_known_launch_surface_program_id(_program_id: &str) -> bool {
// Filled in later after program ids are verified from live corpus and
// official or sufficiently reliable references.
return false;
}
#[cfg(test)]
mod tests {
#[test]
fn associated_token_program_is_ignored() {
let transaction = test_transaction();
let instructions = vec![test_instruction(
0,
Some(crate::ASSOCIATED_TOKEN_PROGRAM_ID.to_string()),
Some("spl-associated-token-account".to_string()),
)];
let specs = super::build_protocol_candidate_specs_for_classification(
&transaction,
1,
&instructions,
"unknown_or_unclassified",
);
assert_eq!(specs.len(), 0);
}
#[test]
fn meteora_dlmm_is_known_dex_protocol() {
let transaction = test_transaction();
let instructions = vec![test_instruction(
0,
Some(crate::METEORA_DLMM_PROGRAM_ID.to_string()),
Some("Meteora DLMM".to_string()),
)];
let specs = super::build_protocol_candidate_specs_for_classification(
&transaction,
1,
&instructions,
"known_dex_program_unclassified",
);
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].program_id, crate::METEORA_DLMM_PROGRAM_ID);
assert_eq!(specs[0].candidate_protocol, Some("meteora_dlmm".to_string()));
}
#[test]
fn known_arbitrage_bot_gets_surface_hint() {
let transaction = test_transaction();
let instructions = vec![test_instruction(
0,
Some(crate::ARBITRAGE_BOT_6MWVT_PROGRAM_ID.to_string()),
None,
)];
let specs = super::build_protocol_candidate_specs_for_classification(
&transaction,
1,
&instructions,
"unknown_or_unclassified",
);
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].candidate_surface, Some("arbitrage_bot".to_string()));
}
fn test_instruction(
instruction_index: u32,
program_id: std::option::Option<std::string::String>,
program_name: std::option::Option<std::string::String>,
) -> crate::ChainInstructionDto {
return crate::ChainInstructionDto::new(
1,
None,
instruction_index,
None,
program_id,
program_name,
None,
"[]".to_string(),
None,
None,
Some(serde_json::json!({}).to_string()),
);
}
fn test_transaction() -> crate::ChainTransactionDto {
let mut transaction = crate::ChainTransactionDto::new(
"signature_1".to_string(),
Some(123),
None,
Some("test".to_string()),
None,
None,
None,
serde_json::json!({}).to_string(),
);
transaction.id = Some(1);
return transaction;
}
#[test]
fn known_dex_candidate_is_built_for_unclassified_known_program() {
let transaction = test_transaction();
let instructions = vec![test_instruction(
0,
Some(crate::METEORA_DAMM_V2_PROGRAM_ID.to_string()),
Some("Meteora".to_string()),
)];
let specs = super::build_protocol_candidate_specs_for_classification(
&transaction,
1,
&instructions,
"known_dex_program_unclassified",
);
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].program_id, crate::METEORA_DAMM_V2_PROGRAM_ID);
assert_eq!(specs[0].candidate_protocol, Some("meteora_damm_v2".to_string()));
}
#[test]
fn ignored_program_is_not_recorded_as_unknown_candidate() {
let transaction = test_transaction();
let instructions = vec![test_instruction(
0,
Some(crate::SPL_TOKEN_PROGRAM_ID.to_string()),
Some("spl-token".to_string()),
)];
let specs = super::build_protocol_candidate_specs_for_classification(
&transaction,
1,
&instructions,
"unknown_or_unclassified",
);
assert_eq!(specs.len(), 0);
}
#[test]
fn unknown_non_ignored_program_is_recorded() {
let transaction = test_transaction();
let instructions = vec![test_instruction(
0,
Some("UnknownProgram111111111111111111111111111111111".to_string()),
Some("unknown swap program".to_string()),
)];
let specs = super::build_protocol_candidate_specs_for_classification(
&transaction,
1,
&instructions,
"unknown_or_unclassified",
);
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].candidate_surface, Some("swap_related".to_string()));
}
}

View File

@@ -82,6 +82,7 @@ pub struct TokenBackfillService {
wallet_observation_service: crate::WalletObservationService,
trade_aggregation_service: crate::TradeAggregationService,
pair_candle_aggregation_service: crate::PairCandleAggregationService,
transaction_classification_service: crate::TransactionClassificationService,
token_metadata_service: crate::TokenMetadataBackfillService,
}
@@ -102,6 +103,8 @@ impl TokenBackfillService {
let trade_aggregation_service = crate::TradeAggregationService::new(database.clone());
let pair_candle_aggregation_service =
crate::PairCandleAggregationService::new(database.clone());
let transaction_classification_service =
crate::TransactionClassificationService::new(database.clone());
let token_metadata_service = crate::TokenMetadataBackfillService::new(
http_pool.clone(),
database.clone(),
@@ -120,6 +123,7 @@ impl TokenBackfillService {
wallet_observation_service,
trade_aggregation_service,
pair_candle_aggregation_service,
transaction_classification_service,
token_metadata_service,
};
}
@@ -436,6 +440,13 @@ impl TokenBackfillService {
Ok(pair_candle_aggregations) => pair_candle_aggregations,
Err(error) => return Err(error),
};
let transaction_classification_result = self
.transaction_classification_service
.classify_transaction_by_signature(signature.as_str())
.await;
if let Err(error) = transaction_classification_result {
return Err(error);
}
return Ok(TokenBackfillSignatureResult {
resolved_transaction_count: 1,
missing_transaction_count: 0,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,216 @@
// file: kb_lib/src/trade_aggregation_context.rs
//! Database context loading for trade aggregation.
//!
//! This module resolves the normalized database context required by
//! `TradeAggregationService`: transaction, decoded events, pool, pair,
//! base/quote token metadata and pool token vault addresses.
/// Transaction-level context used by trade aggregation.
pub(crate) struct TradeAggregationTransactionContext {
/// Persisted transaction row.
pub(crate) transaction: crate::ChainTransactionDto,
/// Internal transaction id.
pub(crate) transaction_id: i64,
/// Decoded DEX events attached to the transaction.
pub(crate) decoded_events: std::vec::Vec<crate::DexDecodedEventDto>,
}
/// Decoded-event-level context used by trade aggregation.
pub(crate) struct TradeAggregationDecodedEventContext {
/// Internal decoded event id.
pub(crate) decoded_event_id: i64,
/// Existing trade event, when this decoded event was already materialized.
pub(crate) existing_trade_event: std::option::Option<crate::TradeEventDto>,
/// Pool account address from the decoded event.
pub(crate) pool_address: std::string::String,
/// Persisted pool row.
pub(crate) pool: crate::PoolDto,
/// Internal pool id.
pub(crate) pool_id: i64,
/// Persisted pair row.
pub(crate) pair: crate::PairDto,
/// Internal pair id.
pub(crate) pair_id: i64,
/// Base token mint, when the token row exists.
pub(crate) base_token_mint: std::option::Option<std::string::String>,
/// Base token decimals, when known.
pub(crate) base_token_decimals: std::option::Option<u8>,
/// Quote token mint, when the token row exists.
pub(crate) quote_token_mint: std::option::Option<std::string::String>,
/// Quote token decimals, when known.
pub(crate) quote_token_decimals: std::option::Option<u8>,
/// Base token vault address, when known.
pub(crate) base_vault_address: std::option::Option<std::string::String>,
/// Quote token vault address, when known.
pub(crate) quote_vault_address: std::option::Option<std::string::String>,
}
/// Loads a transaction and its decoded DEX events from one signature.
pub(crate) async fn load_trade_aggregation_transaction_context(
database: &crate::Database,
signature: &str,
) -> Result<crate::trade_aggregation_context::TradeAggregationTransactionContext, crate::Error> {
let transaction_result =
crate::query_chain_transactions_get_by_signature(database, signature).await;
let transaction_option = match transaction_result {
Ok(transaction_option) => transaction_option,
Err(error) => return Err(error),
};
let transaction = match transaction_option {
Some(transaction) => transaction,
None => {
return Err(crate::Error::InvalidState(format!(
"cannot aggregate trades for unknown transaction '{}'",
signature
)));
},
};
let transaction_id = match transaction.id {
Some(transaction_id) => transaction_id,
None => {
return Err(crate::Error::InvalidState(format!(
"transaction '{}' has no internal id",
signature
)));
},
};
let decoded_events_result =
crate::query_dex_decoded_events_list_by_transaction_id(database, transaction_id).await;
let decoded_events = match decoded_events_result {
Ok(decoded_events) => decoded_events,
Err(error) => return Err(error),
};
return Ok(crate::trade_aggregation_context::TradeAggregationTransactionContext {
transaction,
transaction_id,
decoded_events,
});
}
/// Loads the normalized DB context for one decoded event.
///
/// Returns `Ok(None)` when the decoded event is not materializable yet:
/// missing pool account, pool row or pair row.
pub(crate) async fn load_trade_aggregation_decoded_event_context(
database: &crate::Database,
decoded_event: &crate::DexDecodedEventDto,
) -> Result<
std::option::Option<crate::trade_aggregation_context::TradeAggregationDecodedEventContext>,
crate::Error,
> {
let decoded_event_id = match decoded_event.id {
Some(decoded_event_id) => decoded_event_id,
None => {
return Err(crate::Error::InvalidState("decoded event has no internal id".to_string()));
},
};
let existing_trade_result =
crate::query_trade_events_get_by_decoded_event_id(database, decoded_event_id).await;
let existing_trade_event = match existing_trade_result {
Ok(existing_trade_event) => existing_trade_event,
Err(error) => return Err(error),
};
let pool_address = match decoded_event.pool_account.clone() {
Some(pool_address) => pool_address,
None => return Ok(None),
};
let pool_result = crate::query_pools_get_by_address(database, pool_address.as_str()).await;
let pool_option = match pool_result {
Ok(pool_option) => pool_option,
Err(error) => return Err(error),
};
let pool = match pool_option {
Some(pool) => pool,
None => return Ok(None),
};
let pool_id = match pool.id {
Some(pool_id) => pool_id,
None => {
return Err(crate::Error::InvalidState(format!(
"pool '{}' has no internal id",
pool.address
)));
},
};
let pair_result = crate::query_pairs_get_by_pool_id(database, pool_id).await;
let pair_option = match pair_result {
Ok(pair_option) => pair_option,
Err(error) => return Err(error),
};
let pair = match pair_option {
Some(pair) => pair,
None => return Ok(None),
};
let pair_id = match pair.id {
Some(pair_id) => pair_id,
None => {
return Err(crate::Error::InvalidState(format!(
"pair for pool '{}' has no internal id",
pool_id
)));
},
};
let base_token_result = crate::query_tokens_get_by_id(database, pair.base_token_id).await;
let (base_token_mint, base_token_decimals) = match base_token_result {
Ok(Some(token)) => (Some(token.mint), token.decimals),
Ok(None) => (None, None),
Err(error) => return Err(error),
};
let quote_token_result = crate::query_tokens_get_by_id(database, pair.quote_token_id).await;
let (quote_token_mint, quote_token_decimals) = match quote_token_result {
Ok(Some(token)) => (Some(token.mint), token.decimals),
Ok(None) => (None, None),
Err(error) => return Err(error),
};
let pool_tokens_result = crate::query_pool_tokens_list_by_pool_id(database, pool_id).await;
let pool_tokens = match pool_tokens_result {
Ok(pool_tokens) => pool_tokens,
Err(error) => return Err(error),
};
let base_vault_address =
crate::trade_aggregation_context::find_pool_token_vault_address_by_token_id(
&pool_tokens,
pair.base_token_id,
);
let quote_vault_address =
crate::trade_aggregation_context::find_pool_token_vault_address_by_token_id(
&pool_tokens,
pair.quote_token_id,
);
return Ok(Some(crate::trade_aggregation_context::TradeAggregationDecodedEventContext {
decoded_event_id,
existing_trade_event,
pool_address,
pool,
pool_id,
pair,
pair_id,
base_token_mint,
base_token_decimals,
quote_token_mint,
quote_token_decimals,
base_vault_address,
quote_vault_address,
}));
}
fn find_pool_token_vault_address_by_token_id(
pool_tokens: &[crate::PoolTokenDto],
token_id: i64,
) -> std::option::Option<std::string::String> {
for pool_token in pool_tokens {
if pool_token.token_id != token_id {
continue;
}
let vault_address = match pool_token.vault_address.clone() {
Some(vault_address) => vault_address.trim().to_string(),
None => continue,
};
if vault_address.is_empty() {
continue;
}
return Some(vault_address);
}
return None;
}

View File

@@ -0,0 +1,650 @@
// file: kb_lib/src/trade_amount_resolution.rs
//! Trade amount resolution orchestration.
//!
//! This module resolves base/quote raw amounts and quote/base price for one
//! decoded trade candidate by applying protocol-specific and generic fallback
//! strategies in deterministic order.
/// Input context required to resolve trade amounts.
pub(crate) struct TradeAmountResolutionInput<'a> {
/// Database connection.
pub(crate) database: &'a crate::Database,
/// Persisted transaction row.
pub(crate) transaction: &'a crate::ChainTransactionDto,
/// Decoded DEX event row.
pub(crate) decoded_event: &'a crate::DexDecodedEventDto,
/// Decoded event payload.
pub(crate) payload: &'a serde_json::Value,
/// Pool account address.
pub(crate) pool_address: &'a str,
/// Base token mint, when known.
pub(crate) base_token_mint: std::option::Option<&'a str>,
/// Quote token mint, when known.
pub(crate) quote_token_mint: std::option::Option<&'a str>,
/// Base token decimals, when known.
pub(crate) base_token_decimals: std::option::Option<u8>,
/// Quote token decimals, when known.
pub(crate) quote_token_decimals: std::option::Option<u8>,
/// Base token vault address, when known.
pub(crate) base_vault_address: std::option::Option<&'a str>,
/// Quote token vault address, when known.
pub(crate) quote_vault_address: std::option::Option<&'a str>,
}
/// Resolved raw trade amounts and quote/base price.
#[derive(Debug, Clone)]
pub(crate) struct TradeAmountResolution {
/// Base amount in raw token units.
pub(crate) base_amount_raw: std::option::Option<std::string::String>,
/// Quote amount in raw token units.
pub(crate) quote_amount_raw: std::option::Option<std::string::String>,
/// Quote/base price.
pub(crate) price_quote_per_base: std::option::Option<f64>,
}
/// Resolves trade amounts from payload and protocol-specific fallbacks.
pub(crate) async fn resolve_trade_amounts(
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
) -> Result<crate::trade_amount_resolution::TradeAmountResolution, crate::Error> {
let mut base_amount_raw = crate::trade_amount_resolution::extract_amount_string(
input.payload,
&["baseAmountRaw", "base_amount_raw", "baseAmount", "amountBase", "amountInBase"],
);
let mut quote_amount_raw = crate::trade_amount_resolution::extract_amount_string(
input.payload,
&[
"quoteAmountRaw",
"quote_amount_raw",
"quoteAmount",
"amountQuote",
"amountOutQuote",
],
);
let mut price_quote_per_base = None;
if input.decoded_event.event_kind.starts_with("pump_swap.")
&& (base_amount_raw.is_none()
|| quote_amount_raw.is_none()
|| price_quote_per_base.is_none())
{
let resolution_result = crate::trade_amount_resolution::apply_pump_swap_amount_fallbacks(
input,
&mut base_amount_raw,
&mut quote_amount_raw,
&mut price_quote_per_base,
)
.await;
if let Err(error) = resolution_result {
return Err(error);
}
}
if input.decoded_event.event_kind.starts_with("pump_fun.")
&& (base_amount_raw.is_none()
|| quote_amount_raw.is_none()
|| price_quote_per_base.is_none())
{
let resolution_result = crate::trade_amount_resolution::apply_pump_fun_amount_fallback(
input,
&mut base_amount_raw,
&mut quote_amount_raw,
&mut price_quote_per_base,
);
if let Err(error) = resolution_result {
return Err(error);
}
}
if (input.decoded_event.event_kind.starts_with("raydium_cpmm.")
|| input.decoded_event.event_kind.starts_with("raydium_clmm."))
&& (base_amount_raw.is_none()
|| quote_amount_raw.is_none()
|| price_quote_per_base.is_none())
{
let resolution_result =
crate::trade_amount_resolution::apply_raydium_instruction_amount_fallback(
input,
&mut base_amount_raw,
&mut quote_amount_raw,
&mut price_quote_per_base,
)
.await;
if let Err(error) = resolution_result {
return Err(error);
}
}
if input.decoded_event.event_kind.starts_with("raydium_cpmm.")
&& (base_amount_raw.is_none() || quote_amount_raw.is_none())
{
let resolution_result = crate::trade_amount_resolution::apply_vault_balance_delta_fallback(
input,
input.base_vault_address,
input.quote_vault_address,
&mut base_amount_raw,
&mut quote_amount_raw,
&mut price_quote_per_base,
);
if let Err(error) = resolution_result {
return Err(error);
}
}
if input.decoded_event.event_kind.starts_with("raydium_clmm.")
&& (base_amount_raw.is_none() || quote_amount_raw.is_none())
{
let resolution_result = crate::trade_amount_resolution::apply_vault_balance_delta_fallback(
input,
input.base_vault_address,
input.quote_vault_address,
&mut base_amount_raw,
&mut quote_amount_raw,
&mut price_quote_per_base,
);
if let Err(error) = resolution_result {
return Err(error);
}
}
if price_quote_per_base.is_none() {
price_quote_per_base =
crate::trade_metric_update::compute_price_quote_per_base_from_raw_amounts_with_decimals(
base_amount_raw.as_deref(),
quote_amount_raw.as_deref(),
input.base_token_decimals,
input.quote_token_decimals,
);
}
if price_quote_per_base.is_none() {
price_quote_per_base =
crate::trade_solana_amounts::compute_price_quote_per_base_with_decimals(
input.transaction.meta_json.as_deref(),
input.transaction.transaction_json.as_str(),
input.base_vault_address,
input.quote_vault_address,
);
}
if price_quote_per_base.is_none() {
price_quote_per_base =
crate::trade_metric_update::compute_price_quote_per_base_from_raw_amounts(
base_amount_raw.as_deref(),
quote_amount_raw.as_deref(),
);
}
return Ok(crate::trade_amount_resolution::TradeAmountResolution {
base_amount_raw,
quote_amount_raw,
price_quote_per_base,
});
}
async fn apply_pump_swap_amount_fallbacks(
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
base_amount_raw: &mut std::option::Option<std::string::String>,
quote_amount_raw: &mut std::option::Option<std::string::String>,
price_quote_per_base: &mut std::option::Option<f64>,
) -> Result<(), crate::Error> {
let pool_owner_result = match (input.base_token_mint, input.quote_token_mint) {
(Some(base_mint), Some(quote_mint)) => {
crate::trade_pump_swap_amounts::resolve_pump_swap_trade_amounts_from_pool_balance_deltas(
input.transaction.meta_json.as_deref(),
input.pool_address,
base_mint,
quote_mint,
input.decoded_event.event_kind.as_str(),
input.base_token_decimals,
input.quote_token_decimals,
)
},
_ => Ok(crate::trade_pump_swap_amounts::PumpSwapPoolBalanceDeltaResolution::MissingData),
};
let pool_owner_resolution = match pool_owner_result {
Ok(pool_owner_resolution) => pool_owner_resolution,
Err(error) => return Err(error),
};
let pool_owner_resolution_label = pool_owner_resolution.as_label();
tracing::debug!(
event_kind = %input.decoded_event.event_kind,
pool_account = ?input.decoded_event.pool_account,
decoded_event_id = ?input.decoded_event.id,
transaction_signature = %input.transaction.signature,
base_mint = ?input.base_token_mint,
quote_mint = ?input.quote_token_mint,
pool_owner_resolution = %pool_owner_resolution_label,
"pump_swap pool-owner delta resolution result"
);
match pool_owner_resolution {
crate::trade_pump_swap_amounts::PumpSwapPoolBalanceDeltaResolution::Matched(amounts) => {
*base_amount_raw = Some(amounts.base_amount_raw);
*quote_amount_raw = Some(amounts.quote_amount_raw);
*price_quote_per_base = Some(amounts.price_quote_per_base);
tracing::debug!(
event_kind = %input.decoded_event.event_kind,
pool_account = ?input.decoded_event.pool_account,
decoded_event_id = ?input.decoded_event.id,
base_mint = ?input.base_token_mint,
quote_mint = ?input.quote_token_mint,
base_amount_raw = ?base_amount_raw,
quote_amount_raw = ?quote_amount_raw,
price_quote_per_base = ?price_quote_per_base,
"pump_swap trade amounts recovered from pool-owner token balance deltas"
);
},
crate::trade_pump_swap_amounts::PumpSwapPoolBalanceDeltaResolution::DirectionMismatch => {
tracing::debug!(
event_kind = %input.decoded_event.event_kind,
pool_account = ?input.decoded_event.pool_account,
decoded_event_id = ?input.decoded_event.id,
transaction_signature = %input.transaction.signature,
"pump_swap pool-owner full-transaction delta direction mismatch; continuing with instruction-scoped fallbacks"
);
},
crate::trade_pump_swap_amounts::PumpSwapPoolBalanceDeltaResolution::MissingData => {},
}
let decoded_instruction_index_result =
crate::trade_amount_resolution::load_decoded_instruction_index(
input.database,
input.decoded_event,
)
.await;
let decoded_instruction_index = match decoded_instruction_index_result {
Ok(decoded_instruction_index) => decoded_instruction_index,
Err(error) => return Err(error),
};
let payload_user_base_token_account =
crate::trade_amount_resolution::extract_string_by_candidate_keys(
input.payload,
&["userBaseTokenAccount", "user_base_token_account"],
);
let payload_user_quote_token_account =
crate::trade_amount_resolution::extract_string_by_candidate_keys(
input.payload,
&["userQuoteTokenAccount", "user_quote_token_account"],
);
let payload_pool_base_token_account =
crate::trade_amount_resolution::extract_string_by_candidate_keys(
input.payload,
&["poolBaseTokenAccount", "pool_base_token_account"],
);
let payload_pool_quote_token_account =
crate::trade_amount_resolution::extract_string_by_candidate_keys(
input.payload,
&["poolQuoteTokenAccount", "pool_quote_token_account"],
);
let effective_base_vault_address = match input.base_vault_address {
Some(base_vault_address) => Some(base_vault_address),
None => payload_pool_base_token_account.as_deref(),
};
let effective_quote_vault_address = match input.quote_vault_address {
Some(quote_vault_address) => Some(quote_vault_address),
None => payload_pool_quote_token_account.as_deref(),
};
let (input_vault_address, output_vault_address, input_token_account, output_token_account) =
if input.decoded_event.event_kind.ends_with(".buy") {
(
effective_quote_vault_address,
effective_base_vault_address,
payload_user_quote_token_account.as_deref(),
payload_user_base_token_account.as_deref(),
)
} else if input.decoded_event.event_kind.ends_with(".sell") {
(
effective_base_vault_address,
effective_quote_vault_address,
payload_user_base_token_account.as_deref(),
payload_user_quote_token_account.as_deref(),
)
} else {
(None, None, None, None)
};
let inferred_result =
crate::trade_solana_amounts::extract_trade_amounts_from_instruction_token_transfers(
input.transaction.meta_json.as_deref(),
decoded_instruction_index,
input_vault_address,
output_vault_address,
input_token_account,
output_token_account,
effective_base_vault_address,
effective_quote_vault_address,
);
let inferred = match inferred_result {
Ok(inferred) => inferred,
Err(error) => return Err(error),
};
if base_amount_raw.is_none() {
*base_amount_raw = inferred.0;
}
if quote_amount_raw.is_none() {
*quote_amount_raw = inferred.1;
}
if price_quote_per_base.is_none() {
*price_quote_per_base = inferred.2;
}
if base_amount_raw.is_none() || quote_amount_raw.is_none() {
let fallback_result =
crate::trade_solana_amounts::extract_trade_amounts_from_vault_balance_deltas(
input.transaction.transaction_json.as_str(),
input.transaction.meta_json.as_deref(),
effective_base_vault_address,
effective_quote_vault_address,
);
let fallback = match fallback_result {
Ok(fallback) => fallback,
Err(error) => return Err(error),
};
if base_amount_raw.is_none() {
*base_amount_raw = fallback.0;
}
if quote_amount_raw.is_none() {
*quote_amount_raw = fallback.1;
}
if price_quote_per_base.is_none() {
*price_quote_per_base = fallback.2;
}
}
if base_amount_raw.is_none() || quote_amount_raw.is_none() || price_quote_per_base.is_none() {
let transaction_value_result =
crate::trade_pump_swap_amounts::build_transaction_value_with_meta_json(
input.transaction.transaction_json.as_str(),
input.transaction.meta_json.as_deref(),
);
let transaction_value = match transaction_value_result {
Ok(transaction_value) => transaction_value,
Err(error) => return Err(error),
};
let fallback_amounts = match (input.base_token_mint, input.quote_token_mint) {
(Some(base_mint), Some(quote_mint)) => {
crate::trade_pump_swap_amounts::try_build_pump_swap_trade_amounts_from_token_balance_deltas(
&transaction_value,
base_mint,
quote_mint,
)
},
_ => None,
};
if let Some(fallback_amounts) = fallback_amounts {
if base_amount_raw.is_none() {
*base_amount_raw = crate::trade_pump_swap_amounts::convert_ui_amount_to_raw_string(
fallback_amounts.base_amount,
input.base_token_decimals,
);
}
if quote_amount_raw.is_none() {
*quote_amount_raw = crate::trade_pump_swap_amounts::convert_ui_amount_to_raw_string(
fallback_amounts.quote_amount,
input.quote_token_decimals,
);
}
if price_quote_per_base.is_none() {
*price_quote_per_base = Some(fallback_amounts.price_quote_per_base);
}
tracing::debug!(
event_kind = %input.decoded_event.event_kind,
pool_account = ?input.decoded_event.pool_account,
decoded_event_id = ?input.decoded_event.id,
base_mint = ?input.base_token_mint,
quote_mint = ?input.quote_token_mint,
base_amount_raw = ?base_amount_raw,
quote_amount_raw = ?quote_amount_raw,
price_quote_per_base = ?price_quote_per_base,
"pump_swap trade amounts recovered from token balance deltas"
);
}
}
return Ok(());
}
fn apply_pump_fun_amount_fallback(
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
base_amount_raw: &mut std::option::Option<std::string::String>,
quote_amount_raw: &mut std::option::Option<std::string::String>,
price_quote_per_base: &mut std::option::Option<f64>,
) -> Result<(), crate::Error> {
let inferred_result = crate::trade_solana_amounts::extract_pump_fun_amounts_from_transaction(
input.transaction.transaction_json.as_str(),
input.transaction.meta_json.as_deref(),
input.base_vault_address,
input.quote_vault_address,
);
let inferred = match inferred_result {
Ok(inferred) => inferred,
Err(error) => return Err(error),
};
if base_amount_raw.is_none() {
*base_amount_raw = inferred.0;
}
if quote_amount_raw.is_none() {
*quote_amount_raw = inferred.1;
}
if price_quote_per_base.is_none() {
*price_quote_per_base = inferred.2;
}
return Ok(());
}
async fn apply_raydium_instruction_amount_fallback(
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
base_amount_raw: &mut std::option::Option<std::string::String>,
quote_amount_raw: &mut std::option::Option<std::string::String>,
price_quote_per_base: &mut std::option::Option<f64>,
) -> Result<(), crate::Error> {
let decoded_instruction_index_result =
crate::trade_amount_resolution::load_decoded_instruction_index(
input.database,
input.decoded_event,
)
.await;
let decoded_instruction_index = match decoded_instruction_index_result {
Ok(decoded_instruction_index) => decoded_instruction_index,
Err(error) => return Err(error),
};
let payload_input_vault_address =
crate::trade_amount_resolution::extract_string_by_candidate_keys(
input.payload,
&["inputVault", "input_vault"],
);
let payload_output_vault_address =
crate::trade_amount_resolution::extract_string_by_candidate_keys(
input.payload,
&["outputVault", "output_vault"],
);
let payload_input_token_account =
crate::trade_amount_resolution::extract_string_by_candidate_keys(
input.payload,
&["inputTokenAccount", "input_token_account"],
);
let payload_output_token_account =
crate::trade_amount_resolution::extract_string_by_candidate_keys(
input.payload,
&["outputTokenAccount", "output_token_account"],
);
let payload_base_vault_address =
crate::trade_amount_resolution::extract_string_by_candidate_keys(
input.payload,
&["baseVault", "base_vault"],
);
let payload_quote_vault_address =
crate::trade_amount_resolution::extract_string_by_candidate_keys(
input.payload,
&["quoteVault", "quote_vault"],
);
let effective_base_vault_address = match input.base_vault_address {
Some(base_vault_address) => Some(base_vault_address),
None => payload_base_vault_address.as_deref(),
};
let effective_quote_vault_address = match input.quote_vault_address {
Some(quote_vault_address) => Some(quote_vault_address),
None => payload_quote_vault_address.as_deref(),
};
let inferred_result =
crate::trade_solana_amounts::extract_trade_amounts_from_instruction_token_transfers(
input.transaction.meta_json.as_deref(),
decoded_instruction_index,
payload_input_vault_address.as_deref(),
payload_output_vault_address.as_deref(),
payload_input_token_account.as_deref(),
payload_output_token_account.as_deref(),
effective_base_vault_address,
effective_quote_vault_address,
);
let inferred = match inferred_result {
Ok(inferred) => inferred,
Err(error) => return Err(error),
};
if base_amount_raw.is_none() {
*base_amount_raw = inferred.0;
}
if quote_amount_raw.is_none() {
*quote_amount_raw = inferred.1;
}
if price_quote_per_base.is_none() {
*price_quote_per_base = inferred.2;
}
return Ok(());
}
fn apply_vault_balance_delta_fallback(
input: &crate::trade_amount_resolution::TradeAmountResolutionInput<'_>,
base_vault_address: std::option::Option<&str>,
quote_vault_address: std::option::Option<&str>,
base_amount_raw: &mut std::option::Option<std::string::String>,
quote_amount_raw: &mut std::option::Option<std::string::String>,
price_quote_per_base: &mut std::option::Option<f64>,
) -> Result<(), crate::Error> {
let inferred_result =
crate::trade_solana_amounts::extract_trade_amounts_from_vault_balance_deltas(
input.transaction.transaction_json.as_str(),
input.transaction.meta_json.as_deref(),
base_vault_address,
quote_vault_address,
);
let inferred = match inferred_result {
Ok(inferred) => inferred,
Err(error) => return Err(error),
};
if base_amount_raw.is_none() {
*base_amount_raw = inferred.0;
}
if quote_amount_raw.is_none() {
*quote_amount_raw = inferred.1;
}
if price_quote_per_base.is_none() {
*price_quote_per_base = inferred.2;
}
return Ok(());
}
async fn load_decoded_instruction_index(
database: &crate::Database,
decoded_event: &crate::DexDecodedEventDto,
) -> Result<std::option::Option<u32>, crate::Error> {
let instruction_id = match decoded_event.instruction_id {
Some(instruction_id) => instruction_id,
None => return Ok(None),
};
let instruction_result =
crate::query_chain_instructions_get_by_id(database, instruction_id).await;
let instruction_option = match instruction_result {
Ok(instruction_option) => instruction_option,
Err(error) => return Err(error),
};
match instruction_option {
Some(instruction) => return Ok(Some(instruction.instruction_index)),
None => return Ok(None),
}
}
fn extract_amount_string(
payload: &serde_json::Value,
candidate_keys: &[&str],
) -> std::option::Option<std::string::String> {
return crate::trade_amount_resolution::extract_scalar_as_string_by_candidate_keys(
payload,
candidate_keys,
);
}
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 = crate::trade_amount_resolution::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 = crate::trade_amount_resolution::extract_string_by_candidate_keys(
nested_value,
candidate_keys,
);
if nested_result.is_some() {
return nested_result;
}
}
}
return None;
}
fn extract_scalar_as_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 {
if let Some(text) = direct.as_str() {
return Some(text.to_string());
}
if let Some(number) = direct.as_i64() {
return Some(number.to_string());
}
if let Some(number) = direct.as_u64() {
return Some(number.to_string());
}
if let Some(number) = direct.as_f64() {
return Some(number.to_string());
}
}
}
for nested_value in object.values() {
let nested_result =
crate::trade_amount_resolution::extract_scalar_as_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 =
crate::trade_amount_resolution::extract_scalar_as_string_by_candidate_keys(
nested_value,
candidate_keys,
);
if nested_result.is_some() {
return nested_result;
}
}
}
return None;
}

View File

@@ -0,0 +1,230 @@
// file: kb_lib/src/trade_event_materialization.rs
//! Trade-event materialization.
//!
//! This module persists normalized trade events, updates pair metrics and
//! records the corresponding detection observation/signal.
/// Input required to materialize one normalized trade event.
pub(crate) struct TradeEventMaterializationInput<'a> {
/// Database connection.
pub(crate) database: &'a crate::Database,
/// Detection persistence service used for observations and signals.
pub(crate) persistence: &'a crate::DetectionPersistenceService,
/// Persisted transaction row.
pub(crate) transaction: &'a crate::ChainTransactionDto,
/// Internal transaction id.
pub(crate) transaction_id: i64,
/// Decoded DEX event row.
pub(crate) decoded_event: &'a crate::DexDecodedEventDto,
/// Internal decoded event id.
pub(crate) decoded_event_id: i64,
/// Existing trade event, when the decoded event was already materialized.
pub(crate) existing_trade_event: std::option::Option<crate::TradeEventDto>,
/// Persisted pool row.
pub(crate) pool: &'a crate::PoolDto,
/// Internal pool id.
pub(crate) pool_id: i64,
/// Persisted pair row.
pub(crate) pair: &'a crate::PairDto,
/// Internal pair id.
pub(crate) pair_id: i64,
/// Trade side.
pub(crate) trade_side: crate::SwapTradeSide,
/// Resolved trade amounts.
pub(crate) amount_resolution: &'a crate::trade_amount_resolution::TradeAmountResolution,
}
/// Persists one normalized trade event and updates pair-level metrics.
pub(crate) async fn materialize_trade_event(
input: crate::trade_event_materialization::TradeEventMaterializationInput<'_>,
) -> Result<crate::TradeAggregationResult, crate::Error> {
let base_amount_raw = input.amount_resolution.base_amount_raw.clone();
let quote_amount_raw = input.amount_resolution.quote_amount_raw.clone();
let price_quote_per_base = input.amount_resolution.price_quote_per_base;
let slot_i64 = crate::trade_metric_update::convert_slot_to_i64(input.transaction.slot);
let created_trade_event = input.existing_trade_event.is_none();
let trade_event_dto = crate::TradeEventDto::new(
input.pool.dex_id,
input.pool_id,
input.pair_id,
input.transaction_id,
input.decoded_event_id,
input.transaction.signature.clone(),
slot_i64,
input.trade_side,
input.pair.base_token_id,
input.pair.quote_token_id,
base_amount_raw.clone(),
quote_amount_raw.clone(),
price_quote_per_base,
crate::ObservationSourceKind::Dex,
input.transaction.source_endpoint_name.clone(),
input.decoded_event.payload_json.clone(),
);
tracing::debug!(
event_kind = %input.decoded_event.event_kind,
pool_account = ?input.decoded_event.pool_account,
decoded_event_id = ?input.decoded_event.id,
created_trade_event = created_trade_event,
"trade aggregation candidate"
);
let trade_event_id_result =
crate::query_trade_events_upsert(input.database, &trade_event_dto).await;
let trade_event_id = match trade_event_id_result {
Ok(trade_event_id) => trade_event_id,
Err(error) => return Err(error),
};
let pair_metric_id_result = crate::trade_event_materialization::upsert_pair_metric_for_trade(
input.database,
input.pair_id,
slot_i64,
input.transaction.signature.clone(),
input.trade_side,
base_amount_raw.clone(),
quote_amount_raw.clone(),
price_quote_per_base,
created_trade_event,
)
.await;
let pair_metric_id = match pair_metric_id_result {
Ok(pair_metric_id) => pair_metric_id,
Err(error) => return Err(error),
};
if created_trade_event {
let observation_result =
crate::trade_event_materialization::record_trade_aggregation_observation_and_signal(
input.persistence,
input.transaction,
input.pair_id,
input.pool_id,
trade_event_id,
input.trade_side,
base_amount_raw,
quote_amount_raw,
price_quote_per_base,
)
.await;
if let Err(error) = observation_result {
return Err(error);
}
}
return Ok(crate::TradeAggregationResult {
trade_event_id,
pair_metric_id,
pair_id: input.pair_id,
pool_id: input.pool_id,
created_trade_event,
});
}
async fn upsert_pair_metric_for_trade(
database: &crate::Database,
pair_id: i64,
slot_i64: std::option::Option<i64>,
signature: std::string::String,
trade_side: crate::SwapTradeSide,
base_amount_raw: std::option::Option<std::string::String>,
quote_amount_raw: std::option::Option<std::string::String>,
price_quote_per_base: std::option::Option<f64>,
created_trade_event: bool,
) -> Result<i64, crate::Error> {
let pair_metric_result = crate::query_pair_metrics_get_by_pair_id(database, pair_id).await;
let pair_metric_option = match pair_metric_result {
Ok(pair_metric_option) => pair_metric_option,
Err(error) => return Err(error),
};
if let Some(existing_metric) = pair_metric_option {
let existing_metric_id = match existing_metric.id {
Some(existing_metric_id) => existing_metric_id,
None => {
return Err(crate::Error::InvalidState(
"pair metric has no internal id".to_string(),
));
},
};
if created_trade_event {
let mut updated_metric = existing_metric.clone();
crate::trade_metric_update::apply_trade_to_pair_metric(
&mut updated_metric,
slot_i64,
signature,
trade_side,
base_amount_raw,
quote_amount_raw,
price_quote_per_base,
);
let upsert_result = crate::query_pair_metrics_upsert(database, &updated_metric).await;
if let Err(error) = upsert_result {
return Err(error);
}
}
return Ok(existing_metric_id);
}
let mut new_metric = crate::PairMetricDto::new(pair_id);
crate::trade_metric_update::apply_trade_to_pair_metric(
&mut new_metric,
slot_i64,
signature,
trade_side,
base_amount_raw,
quote_amount_raw,
price_quote_per_base,
);
let upsert_result = crate::query_pair_metrics_upsert(database, &new_metric).await;
match upsert_result {
Ok(pair_metric_id) => return Ok(pair_metric_id),
Err(error) => return Err(error),
}
}
async fn record_trade_aggregation_observation_and_signal(
persistence: &crate::DetectionPersistenceService,
transaction: &crate::ChainTransactionDto,
pair_id: i64,
pool_id: i64,
trade_event_id: i64,
trade_side: crate::SwapTradeSide,
base_amount_raw: std::option::Option<std::string::String>,
quote_amount_raw: std::option::Option<std::string::String>,
price_quote_per_base: std::option::Option<f64>,
) -> Result<(), crate::Error> {
let payload = serde_json::json!({
"pairId": pair_id,
"poolId": pool_id,
"tradeEventId": trade_event_id,
"tradeSide": format!("{:?}", trade_side),
"baseAmountRaw": base_amount_raw,
"quoteAmountRaw": quote_amount_raw,
"priceQuotePerBase": price_quote_per_base,
"transactionSignature": transaction.signature
});
let observation_result = persistence
.record_observation(&crate::DetectionObservationInput::new(
"dex.trade_aggregation".to_string(),
crate::ObservationSourceKind::Dex,
transaction.source_endpoint_name.clone(),
transaction.signature.clone(),
transaction.slot,
payload.clone(),
))
.await;
let observation_id = match observation_result {
Ok(observation_id) => observation_id,
Err(error) => return Err(error),
};
let signal_result = persistence
.record_signal(&crate::DetectionSignalInput::new(
"signal.dex.trade_aggregation.recorded".to_string(),
crate::AnalysisSignalSeverity::Low,
transaction.signature.clone(),
Some(observation_id),
None,
payload,
))
.await;
if let Err(error) = signal_result {
return Err(error);
}
return Ok(());
}

View File

@@ -0,0 +1,279 @@
// file: kb_lib/src/trade_metric_update.rs
//! Trade metric update and basic trade-pricing helpers.
//!
//! This module contains pure helpers used by trade aggregation:
//! pricing validation, raw amount accumulation and pair metric updates.
/// Returns true when a decoded trade has enough positive values to be persisted.
pub(crate) fn is_priced_trade_event(
base_amount_raw: std::option::Option<&str>,
quote_amount_raw: std::option::Option<&str>,
price_quote_per_base: std::option::Option<f64>,
) -> bool {
let base_amount_raw = match base_amount_raw {
Some(base_amount_raw) => base_amount_raw.trim(),
None => return false,
};
if base_amount_raw.is_empty() {
return false;
}
let base_amount_result = base_amount_raw.parse::<i128>();
let base_amount = match base_amount_result {
Ok(base_amount) => base_amount,
Err(_) => return false,
};
if base_amount <= 0 {
return false;
}
let quote_amount_raw = match quote_amount_raw {
Some(quote_amount_raw) => quote_amount_raw.trim(),
None => return false,
};
if quote_amount_raw.is_empty() {
return false;
}
let quote_amount_result = quote_amount_raw.parse::<i128>();
let quote_amount = match quote_amount_result {
Ok(quote_amount) => quote_amount,
Err(_) => return false,
};
if quote_amount <= 0 {
return false;
}
let price = match price_quote_per_base {
Some(price) => price,
None => return false,
};
if !price.is_finite() {
return false;
}
return price > 0.0;
}
/// Converts an optional Solana slot to an optional signed database slot.
pub(crate) fn convert_slot_to_i64(slot: std::option::Option<u64>) -> std::option::Option<i64> {
match slot {
Some(slot) => match i64::try_from(slot) {
Ok(slot) => return Some(slot),
Err(_) => return None,
},
None => return None,
}
}
/// Applies one newly-created trade event to a pair metric.
pub(crate) fn apply_trade_to_pair_metric(
metric: &mut crate::PairMetricDto,
slot: std::option::Option<i64>,
signature: std::string::String,
trade_side: crate::SwapTradeSide,
base_amount_raw: std::option::Option<std::string::String>,
quote_amount_raw: std::option::Option<std::string::String>,
price_quote_per_base: std::option::Option<f64>,
) {
metric.trade_count += 1;
if trade_side == crate::SwapTradeSide::BuyBase {
metric.buy_count += 1;
}
if trade_side == crate::SwapTradeSide::SellBase {
metric.sell_count += 1;
}
if metric.first_slot.is_none() {
metric.first_slot = slot;
}
if metric.first_signature.is_none() {
metric.first_signature = Some(signature.clone());
}
metric.last_slot = slot;
metric.last_signature = Some(signature);
metric.cumulative_base_amount_raw = crate::trade_metric_update::add_raw_amounts(
metric.cumulative_base_amount_raw.clone(),
base_amount_raw,
);
metric.cumulative_quote_amount_raw = crate::trade_metric_update::add_raw_amounts(
metric.cumulative_quote_amount_raw.clone(),
quote_amount_raw,
);
if price_quote_per_base.is_some() {
metric.last_price_quote_per_base = price_quote_per_base;
}
metric.updated_at = chrono::Utc::now();
}
/// Adds two optional raw integer amount strings.
pub(crate) fn add_raw_amounts(
left: std::option::Option<std::string::String>,
right: std::option::Option<std::string::String>,
) -> std::option::Option<std::string::String> {
match (left, right) {
(None, None) => return None,
(Some(left), None) => return Some(left),
(None, Some(right)) => return Some(right),
(Some(left), Some(right)) => {
let left_value_result = left.parse::<i128>();
let left_value = match left_value_result {
Ok(left_value) => left_value,
Err(_) => return Some(left),
};
let right_value_result = right.parse::<i128>();
let right_value = match right_value_result {
Ok(right_value) => right_value,
Err(_) => return Some(left),
};
return Some((left_value + right_value).to_string());
},
}
}
/// Computes quote/base price from raw amounts and token decimals.
pub(crate) fn compute_price_quote_per_base_from_raw_amounts_with_decimals(
base_amount_raw: std::option::Option<&str>,
quote_amount_raw: std::option::Option<&str>,
base_decimals: std::option::Option<u8>,
quote_decimals: std::option::Option<u8>,
) -> std::option::Option<f64> {
let base_decimals = match base_decimals {
Some(base_decimals) => base_decimals,
None => return None,
};
let quote_decimals = match quote_decimals {
Some(quote_decimals) => quote_decimals,
None => return None,
};
let base_amount_raw = match base_amount_raw {
Some(base_amount_raw) => base_amount_raw.trim(),
None => return None,
};
let quote_amount_raw = match quote_amount_raw {
Some(quote_amount_raw) => quote_amount_raw.trim(),
None => return None,
};
if base_amount_raw.is_empty() || quote_amount_raw.is_empty() {
return None;
}
let base_amount_result = base_amount_raw.parse::<f64>();
let base_amount = match base_amount_result {
Ok(base_amount) => base_amount,
Err(_) => return None,
};
let quote_amount_result = quote_amount_raw.parse::<f64>();
let quote_amount = match quote_amount_result {
Ok(quote_amount) => quote_amount,
Err(_) => return None,
};
if base_amount <= 0.0 || quote_amount <= 0.0 {
return None;
}
let base_scale = 10_f64.powi(i32::from(base_decimals));
let quote_scale = 10_f64.powi(i32::from(quote_decimals));
if base_scale <= 0.0 || quote_scale <= 0.0 {
return None;
}
let base_ui_amount = base_amount / base_scale;
let quote_ui_amount = quote_amount / quote_scale;
if base_ui_amount <= 0.0 || quote_ui_amount <= 0.0 {
return None;
}
return Some(quote_ui_amount / base_ui_amount);
}
/// Computes quote/base price from raw amount strings without decimals.
pub(crate) fn compute_price_quote_per_base_from_raw_amounts(
base_amount_raw: std::option::Option<&str>,
quote_amount_raw: std::option::Option<&str>,
) -> std::option::Option<f64> {
let base_amount_raw = match base_amount_raw {
Some(base_amount_raw) => base_amount_raw.trim(),
None => return None,
};
let quote_amount_raw = match quote_amount_raw {
Some(quote_amount_raw) => quote_amount_raw.trim(),
None => return None,
};
if base_amount_raw.is_empty() || quote_amount_raw.is_empty() {
return None;
}
let base_amount_result = base_amount_raw.parse::<f64>();
let base_amount = match base_amount_result {
Ok(base_amount) => base_amount,
Err(_) => return None,
};
let quote_amount_result = quote_amount_raw.parse::<f64>();
let quote_amount = match quote_amount_result {
Ok(quote_amount) => quote_amount,
Err(_) => return None,
};
if base_amount <= 0.0 {
return None;
}
return Some(quote_amount / base_amount);
}
#[cfg(test)]
mod tests {
#[test]
fn priced_trade_event_rejects_unpriced_values() {
let result = super::is_priced_trade_event(None, Some("2500"), Some(2.5));
assert!(!result);
let result = super::is_priced_trade_event(Some("1000"), None, Some(2.5));
assert!(!result);
let result = super::is_priced_trade_event(Some("1000"), Some("2500"), None);
assert!(!result);
let result = super::is_priced_trade_event(Some("0"), Some("2500"), Some(2.5));
assert!(!result);
let result = super::is_priced_trade_event(Some("1000"), Some("0"), Some(2.5));
assert!(!result);
let result = super::is_priced_trade_event(Some("-1"), Some("2500"), Some(2.5));
assert!(!result);
let result = super::is_priced_trade_event(Some("1000"), Some("-1"), Some(2.5));
assert!(!result);
let result = super::is_priced_trade_event(Some("abc"), Some("2500"), Some(2.5));
assert!(!result);
let result = super::is_priced_trade_event(Some("1000"), Some("abc"), Some(2.5));
assert!(!result);
let result = super::is_priced_trade_event(Some("1000"), Some("2500"), Some(0.0));
assert!(!result);
let result = super::is_priced_trade_event(Some("1000"), Some("2500"), Some(f64::NAN));
assert!(!result);
let result = super::is_priced_trade_event(Some("1000"), Some("2500"), Some(2.5));
assert!(result);
}
#[test]
fn raw_amounts_are_added_when_both_are_valid() {
let result = super::add_raw_amounts(Some("1000".to_string()), Some("2500".to_string()));
assert_eq!(result, Some("3500".to_string()));
}
#[test]
fn raw_amount_addition_keeps_left_when_right_is_invalid() {
let result = super::add_raw_amounts(Some("1000".to_string()), Some("abc".to_string()));
assert_eq!(result, Some("1000".to_string()));
}
#[test]
fn price_with_decimals_is_computed() {
let price = super::compute_price_quote_per_base_from_raw_amounts_with_decimals(
Some("1000000"),
Some("2500000000"),
Some(6),
Some(9),
);
assert_eq!(price, Some(2.5));
}
#[test]
fn price_without_decimals_is_computed() {
let price =
super::compute_price_quote_per_base_from_raw_amounts(Some("1000"), Some("2500"));
assert_eq!(price, Some(2.5));
}
#[test]
fn overflowing_slot_is_ignored() {
let slot = super::convert_slot_to_i64(Some(u64::MAX));
assert_eq!(slot, None);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
// file: kb_lib/src/trade_side_resolution.rs
//! Trade-side resolution helpers.
//!
//! This module resolves a normalized `SwapTradeSide` from a decoded event kind
//! and optional decoded payload metadata.
/// Resolves the normalized trade side from payload metadata and event kind.
pub(crate) fn extract_trade_side(
event_kind: &str,
payload: &serde_json::Value,
) -> crate::SwapTradeSide {
let trade_side_option = crate::trade_side_resolution::extract_string_by_candidate_keys(
payload,
&["tradeSide", "trade_side"],
);
match trade_side_option.as_deref() {
Some("BuyBase") => return crate::SwapTradeSide::BuyBase,
Some("buy") => return crate::SwapTradeSide::BuyBase,
Some("BUY") => return crate::SwapTradeSide::BuyBase,
Some("SellBase") => return crate::SwapTradeSide::SellBase,
Some("sell") => return crate::SwapTradeSide::SellBase,
Some("SELL") => return crate::SwapTradeSide::SellBase,
_ => {},
}
if event_kind.ends_with(".buy") {
return crate::SwapTradeSide::BuyBase;
}
if event_kind.ends_with(".sell") {
return crate::SwapTradeSide::SellBase;
}
return crate::SwapTradeSide::Unknown;
}
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 = crate::trade_side_resolution::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 = crate::trade_side_resolution::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 payload_trade_side_wins_over_event_kind() {
let payload = serde_json::json!({
"tradeSide": "SellBase"
});
let side = super::extract_trade_side("pump_swap.buy", &payload);
assert_eq!(side, crate::SwapTradeSide::SellBase);
}
#[test]
fn nested_payload_trade_side_is_resolved() {
let payload = serde_json::json!({
"decoded": {
"meta": {
"trade_side": "buy"
}
}
});
let side = super::extract_trade_side("raydium_cpmm.swap_base_input", &payload);
assert_eq!(side, crate::SwapTradeSide::BuyBase);
}
#[test]
fn buy_suffix_is_resolved_when_payload_has_no_side() {
let payload = serde_json::json!({});
let side = super::extract_trade_side("pump_fun.buy", &payload);
assert_eq!(side, crate::SwapTradeSide::BuyBase);
}
#[test]
fn sell_suffix_is_resolved_when_payload_has_no_side() {
let payload = serde_json::json!({});
let side = super::extract_trade_side("pump_fun.sell", &payload);
assert_eq!(side, crate::SwapTradeSide::SellBase);
}
#[test]
fn unknown_side_is_returned_when_no_hint_exists() {
let payload = serde_json::json!({});
let side = super::extract_trade_side("raydium_cpmm.swap_base_input", &payload);
assert_eq!(side, crate::SwapTradeSide::Unknown);
}
}

View File

@@ -0,0 +1,913 @@
// file: kb_lib/src/trade_solana_amounts.rs
//! Solana transaction/meta trade amount extraction helpers.
//!
//! This module contains generic fallback logic based on transaction JSON,
//! transaction meta, account keys, token balances, native balances and
//! instruction-scoped SPL token transfers.
/// Extracted base/quote amounts and optional quote/base price.
pub(crate) type ExtractedTradeAmounts = (
std::option::Option<std::string::String>,
std::option::Option<std::string::String>,
std::option::Option<f64>,
);
/// Extracts base/quote amounts from token-account vault balance deltas.
pub(crate) fn extract_trade_amounts_from_vault_balance_deltas(
transaction_json: &str,
meta_json: std::option::Option<&str>,
base_vault_address: std::option::Option<&str>,
quote_vault_address: std::option::Option<&str>,
) -> Result<crate::trade_solana_amounts::ExtractedTradeAmounts, crate::Error> {
let meta_json = match meta_json {
Some(meta_json) => meta_json,
None => return Ok((None, None, None)),
};
let transaction_value_result = serde_json::from_str::<serde_json::Value>(transaction_json);
let transaction_value = match transaction_value_result {
Ok(transaction_value) => transaction_value,
Err(error) => {
return Err(crate::Error::Json(format!(
"cannot parse transaction_json for vault balance amount extraction: {}",
error
)));
},
};
let meta_value_result = serde_json::from_str::<serde_json::Value>(meta_json);
let meta_value = match meta_value_result {
Ok(meta_value) => meta_value,
Err(error) => {
return Err(crate::Error::Json(format!(
"cannot parse meta_json for vault balance amount extraction: {}",
error
)));
},
};
let account_keys_result =
crate::trade_solana_amounts::extract_transaction_account_keys(&transaction_value);
let account_keys = match account_keys_result {
Ok(account_keys) => account_keys,
Err(error) => return Err(error),
};
let pre_balances_result = crate::trade_solana_amounts::extract_token_balance_map(
&meta_value,
&account_keys,
"preTokenBalances",
);
let pre_balances = match pre_balances_result {
Ok(pre_balances) => pre_balances,
Err(error) => return Err(error),
};
let post_balances_result = crate::trade_solana_amounts::extract_token_balance_map(
&meta_value,
&account_keys,
"postTokenBalances",
);
let post_balances = match post_balances_result {
Ok(post_balances) => post_balances,
Err(error) => return Err(error),
};
let mut base_amount_raw = None;
let mut quote_amount_raw = None;
let mut price_quote_per_base = None;
if let Some(base_vault_address) = base_vault_address {
let base_pre = pre_balances.get(base_vault_address);
let base_post = post_balances.get(base_vault_address);
let base_pre_raw = match base_pre {
Some(value) => Some(value.0.clone()),
None => None,
};
let base_post_raw = match base_post {
Some(value) => Some(value.0.clone()),
None => None,
};
base_amount_raw =
crate::trade_solana_amounts::compute_amount_delta_abs(base_pre_raw, base_post_raw);
let base_pre_ui = match base_pre {
Some(value) => value.1,
None => None,
};
let base_post_ui = match base_post {
Some(value) => value.1,
None => None,
};
let base_delta_ui =
crate::trade_solana_amounts::compute_ui_delta_abs(base_pre_ui, base_post_ui);
if let Some(quote_vault_address) = quote_vault_address {
let quote_pre = pre_balances.get(quote_vault_address);
let quote_post = post_balances.get(quote_vault_address);
let quote_pre_raw = match quote_pre {
Some(value) => Some(value.0.clone()),
None => None,
};
let quote_post_raw = match quote_post {
Some(value) => Some(value.0.clone()),
None => None,
};
quote_amount_raw = crate::trade_solana_amounts::compute_amount_delta_abs(
quote_pre_raw,
quote_post_raw,
);
let quote_pre_ui = match quote_pre {
Some(value) => value.1,
None => None,
};
let quote_post_ui = match quote_post {
Some(value) => value.1,
None => None,
};
let quote_delta_ui =
crate::trade_solana_amounts::compute_ui_delta_abs(quote_pre_ui, quote_post_ui);
if let (Some(base_delta_ui), Some(quote_delta_ui)) = (base_delta_ui, quote_delta_ui) {
if base_delta_ui > 0.0 {
price_quote_per_base = Some(quote_delta_ui / base_delta_ui);
}
}
}
}
return Ok((base_amount_raw, quote_amount_raw, price_quote_per_base));
}
/// Extracts base/quote amounts from instruction-scoped SPL token transfers.
pub(crate) fn extract_trade_amounts_from_instruction_token_transfers(
meta_json: std::option::Option<&str>,
instruction_index: std::option::Option<u32>,
input_vault_address: std::option::Option<&str>,
output_vault_address: std::option::Option<&str>,
input_token_account: std::option::Option<&str>,
output_token_account: std::option::Option<&str>,
base_vault_address: std::option::Option<&str>,
quote_vault_address: std::option::Option<&str>,
) -> Result<crate::trade_solana_amounts::ExtractedTradeAmounts, crate::Error> {
let meta_json = match meta_json {
Some(meta_json) => meta_json,
None => return Ok((None, None, None)),
};
let instruction_index = match instruction_index {
Some(instruction_index) => u64::from(instruction_index),
None => return Ok((None, None, None)),
};
let input_vault_address = match input_vault_address {
Some(input_vault_address) => input_vault_address.trim(),
None => return Ok((None, None, None)),
};
let output_vault_address = match output_vault_address {
Some(output_vault_address) => output_vault_address.trim(),
None => return Ok((None, None, None)),
};
let input_token_account = match input_token_account {
Some(input_token_account) => input_token_account.trim(),
None => return Ok((None, None, None)),
};
let output_token_account = match output_token_account {
Some(output_token_account) => output_token_account.trim(),
None => return Ok((None, None, None)),
};
let base_vault_address = match base_vault_address {
Some(base_vault_address) => base_vault_address.trim(),
None => return Ok((None, None, None)),
};
let quote_vault_address = match quote_vault_address {
Some(quote_vault_address) => quote_vault_address.trim(),
None => return Ok((None, None, None)),
};
if input_vault_address.is_empty()
|| output_vault_address.is_empty()
|| input_token_account.is_empty()
|| output_token_account.is_empty()
|| base_vault_address.is_empty()
|| quote_vault_address.is_empty()
{
return Ok((None, None, None));
}
let meta_value_result = serde_json::from_str::<serde_json::Value>(meta_json);
let meta_value = match meta_value_result {
Ok(meta_value) => meta_value,
Err(error) => {
return Err(crate::Error::Json(format!(
"cannot parse meta_json for instruction-scoped token transfer amount extraction: {}",
error
)));
},
};
let inner_groups_option =
meta_value.get("innerInstructions").and_then(|value| return value.as_array());
let inner_groups = match inner_groups_option {
Some(inner_groups) => inner_groups,
None => return Ok((None, None, None)),
};
let mut input_amount_raw = None;
let mut output_amount_raw = None;
for inner_group in inner_groups {
let group_index_option = inner_group.get("index").and_then(|value| return value.as_u64());
let group_index = match group_index_option {
Some(group_index) => group_index,
None => continue,
};
if group_index != instruction_index {
continue;
}
let instructions_option =
inner_group.get("instructions").and_then(|value| return value.as_array());
let instructions = match instructions_option {
Some(instructions) => instructions,
None => continue,
};
for instruction in instructions {
if !crate::trade_solana_amounts::is_spl_token_transfer_instruction(instruction) {
continue;
}
let parsed = match instruction.get("parsed") {
Some(parsed) => parsed,
None => continue,
};
let info = match parsed.get("info") {
Some(info) => info,
None => continue,
};
let source_option =
crate::trade_solana_amounts::extract_string_by_candidate_keys(info, &["source"]);
let source = match source_option {
Some(source) => source,
None => continue,
};
let destination_option = crate::trade_solana_amounts::extract_string_by_candidate_keys(
info,
&["destination"],
);
let destination = match destination_option {
Some(destination) => destination,
None => continue,
};
let amount_option =
crate::trade_solana_amounts::extract_scalar_as_string_by_candidate_keys(
info,
&["amount"],
);
let amount = match amount_option {
Some(amount) => amount,
None => continue,
};
if input_amount_raw.is_none()
&& crate::trade_solana_amounts::account_equals(source.as_str(), input_token_account)
&& crate::trade_solana_amounts::account_equals(
destination.as_str(),
input_vault_address,
)
{
input_amount_raw = Some(amount.clone());
continue;
}
if output_amount_raw.is_none()
&& crate::trade_solana_amounts::account_equals(
source.as_str(),
output_vault_address,
)
&& crate::trade_solana_amounts::account_equals(
destination.as_str(),
output_token_account,
)
{
output_amount_raw = Some(amount);
continue;
}
}
}
if input_amount_raw.is_none() && output_amount_raw.is_none() {
return Ok((None, None, None));
}
if crate::trade_solana_amounts::account_equals(input_vault_address, base_vault_address)
&& crate::trade_solana_amounts::account_equals(output_vault_address, quote_vault_address)
{
return Ok((input_amount_raw, output_amount_raw, None));
}
if crate::trade_solana_amounts::account_equals(input_vault_address, quote_vault_address)
&& crate::trade_solana_amounts::account_equals(output_vault_address, base_vault_address)
{
return Ok((output_amount_raw, input_amount_raw, None));
}
return Ok((None, None, None));
}
/// Extracts Pump.fun amounts from transaction token/native balance deltas.
pub(crate) fn extract_pump_fun_amounts_from_transaction(
transaction_json: &str,
meta_json: std::option::Option<&str>,
base_vault_address: std::option::Option<&str>,
quote_native_address: std::option::Option<&str>,
) -> Result<crate::trade_solana_amounts::ExtractedTradeAmounts, crate::Error> {
let meta_json = match meta_json {
Some(meta_json) => meta_json,
None => return Ok((None, None, None)),
};
let transaction_value_result = serde_json::from_str::<serde_json::Value>(transaction_json);
let transaction_value = match transaction_value_result {
Ok(transaction_value) => transaction_value,
Err(error) => {
return Err(crate::Error::Json(format!(
"cannot parse transaction_json for pump_fun amount extraction: {}",
error
)));
},
};
let meta_value_result = serde_json::from_str::<serde_json::Value>(meta_json);
let meta_value = match meta_value_result {
Ok(meta_value) => meta_value,
Err(error) => {
return Err(crate::Error::Json(format!(
"cannot parse meta_json for pump_fun amount extraction: {}",
error
)));
},
};
let account_keys_result =
crate::trade_solana_amounts::extract_transaction_account_keys(&transaction_value);
let account_keys = match account_keys_result {
Ok(account_keys) => account_keys,
Err(error) => return Err(error),
};
let pre_balances_result = crate::trade_solana_amounts::extract_token_balance_map(
&meta_value,
&account_keys,
"preTokenBalances",
);
let pre_balances = match pre_balances_result {
Ok(pre_balances) => pre_balances,
Err(error) => return Err(error),
};
let post_balances_result = crate::trade_solana_amounts::extract_token_balance_map(
&meta_value,
&account_keys,
"postTokenBalances",
);
let post_balances = match post_balances_result {
Ok(post_balances) => post_balances,
Err(error) => return Err(error),
};
let mut base_amount_raw = None;
let mut quote_amount_raw = None;
let mut price_quote_per_base = None;
let mut base_delta_ui = None;
if let Some(base_vault_address) = base_vault_address {
let base_pre = pre_balances.get(base_vault_address);
let base_post = post_balances.get(base_vault_address);
let base_pre_raw = match base_pre {
Some(value) => Some(value.0.clone()),
None => None,
};
let base_post_raw = match base_post {
Some(value) => Some(value.0.clone()),
None => None,
};
base_amount_raw =
crate::trade_solana_amounts::compute_amount_delta_abs(base_pre_raw, base_post_raw);
let base_pre_ui = match base_pre {
Some(value) => value.1,
None => None,
};
let base_post_ui = match base_post {
Some(value) => value.1,
None => None,
};
base_delta_ui =
crate::trade_solana_amounts::compute_ui_delta_abs(base_pre_ui, base_post_ui);
}
if let Some(quote_native_address) = quote_native_address {
let quote_delta_result =
crate::trade_solana_amounts::extract_native_balance_delta_by_address(
&meta_value,
&account_keys,
quote_native_address,
);
let quote_delta = match quote_delta_result {
Ok(quote_delta) => quote_delta,
Err(error) => return Err(error),
};
if let Some(quote_delta_lamports) = quote_delta {
quote_amount_raw = Some(quote_delta_lamports.to_string());
let quote_delta_ui = quote_delta_lamports as f64 / 1_000_000_000.0;
if let Some(base_delta_ui) = base_delta_ui {
if base_delta_ui > 0.0 {
price_quote_per_base = Some(quote_delta_ui / base_delta_ui);
}
}
}
}
return Ok((base_amount_raw, quote_amount_raw, price_quote_per_base));
}
/// Computes quote/base price from vault balance deltas.
pub(crate) fn compute_price_quote_per_base_with_decimals(
meta_json: std::option::Option<&str>,
transaction_json: &str,
base_vault_address: std::option::Option<&str>,
quote_vault_address: std::option::Option<&str>,
) -> std::option::Option<f64> {
let inferred_result =
crate::trade_solana_amounts::extract_trade_amounts_from_vault_balance_deltas(
transaction_json,
meta_json,
base_vault_address,
quote_vault_address,
);
let inferred = match inferred_result {
Ok(inferred) => inferred,
Err(_) => return None,
};
return inferred.2;
}
fn is_spl_token_transfer_instruction(instruction: &serde_json::Value) -> bool {
let program_id_option = instruction.get("programId").and_then(|value| return value.as_str());
if let Some(program_id) = program_id_option {
let spl_token_program_id = crate::SPL_TOKEN_PROGRAM_ID.to_string();
let spl_token_2022_program_id = crate::SPL_TOKEN_2022_PROGRAM_ID.to_string();
if program_id != spl_token_program_id.as_str()
&& program_id != spl_token_2022_program_id.as_str()
{
return false;
}
}
let parsed_type_option = instruction
.get("parsed")
.and_then(|parsed| return parsed.get("type"))
.and_then(|value| return value.as_str());
match parsed_type_option {
Some("transfer") => return true,
Some("transferChecked") => return true,
_ => return false,
}
}
fn account_equals(left: &str, right: &str) -> bool {
let left = left.trim();
let right = right.trim();
if left.is_empty() || right.is_empty() {
return false;
}
return left == right;
}
fn extract_native_balance_delta_by_address(
meta_value: &serde_json::Value,
account_keys: &[std::string::String],
address: &str,
) -> Result<std::option::Option<u64>, crate::Error> {
let mut account_index = None;
for (index, account_key) in account_keys.iter().enumerate() {
if account_key.as_str() == address {
account_index = Some(index);
break;
}
}
let account_index = match account_index {
Some(account_index) => account_index,
None => return Ok(None),
};
let pre_balances_option =
meta_value.get("preBalances").and_then(|value| return value.as_array());
let post_balances_option =
meta_value.get("postBalances").and_then(|value| return value.as_array());
let pre_balances = match pre_balances_option {
Some(pre_balances) => pre_balances,
None => return Ok(None),
};
let post_balances = match post_balances_option {
Some(post_balances) => post_balances,
None => return Ok(None),
};
if account_index >= pre_balances.len() || account_index >= post_balances.len() {
return Ok(None);
}
let pre_balance = match pre_balances[account_index].as_u64() {
Some(pre_balance) => pre_balance,
None => return Ok(None),
};
let post_balance = match post_balances[account_index].as_u64() {
Some(post_balance) => post_balance,
None => return Ok(None),
};
if post_balance >= pre_balance {
return Ok(Some(post_balance - pre_balance));
}
return Ok(Some(pre_balance - post_balance));
}
fn extract_transaction_account_keys(
transaction_value: &serde_json::Value,
) -> Result<std::vec::Vec<std::string::String>, crate::Error> {
let candidate_arrays = [
transaction_value
.get("message")
.and_then(|value| return value.get("accountKeys")),
transaction_value
.get("transaction")
.and_then(|value| return value.get("message"))
.and_then(|value| return value.get("accountKeys")),
transaction_value
.get("transaction")
.and_then(|value| return value.get("transaction"))
.and_then(|value| return value.get("message"))
.and_then(|value| return value.get("accountKeys")),
transaction_value.get("accountKeys"),
];
for candidate_array_option in candidate_arrays {
let candidate_array = match candidate_array_option {
Some(candidate_array) => candidate_array,
None => continue,
};
let array = match candidate_array.as_array() {
Some(array) => array,
None => continue,
};
let mut account_keys = std::vec::Vec::new();
for item in array {
if let Some(value) = item.as_str() {
account_keys.push(value.to_string());
continue;
}
let pubkey_option = item.get("pubkey").and_then(|value| return value.as_str());
if let Some(pubkey) = pubkey_option {
account_keys.push(pubkey.to_string());
continue;
}
}
if !account_keys.is_empty() {
return Ok(account_keys);
}
}
return Err(crate::Error::Json(
"cannot extract accountKeys from transaction_json".to_string(),
));
}
fn extract_token_balance_map(
meta_value: &serde_json::Value,
account_keys: &[std::string::String],
field_name: &str,
) -> Result<
std::collections::BTreeMap<
std::string::String,
(std::string::String, std::option::Option<f64>),
>,
crate::Error,
> {
let mut result = std::collections::BTreeMap::<
std::string::String,
(std::string::String, std::option::Option<f64>),
>::new();
let balances_option = meta_value.get(field_name).and_then(|value| return value.as_array());
let balances = match balances_option {
Some(balances) => balances,
None => return Ok(result),
};
for balance in balances {
let account_index_option =
balance.get("accountIndex").and_then(|value| return value.as_u64());
let account_index = match account_index_option {
Some(account_index) => account_index as usize,
None => continue,
};
if account_index >= account_keys.len() {
continue;
}
let account_address = account_keys[account_index].clone();
let ui_token_amount = match balance.get("uiTokenAmount") {
Some(ui_token_amount) => ui_token_amount,
None => continue,
};
let raw_amount_option =
ui_token_amount.get("amount").and_then(|value| return value.as_str());
let raw_amount = match raw_amount_option {
Some(raw_amount) => raw_amount.to_string(),
None => continue,
};
let ui_amount_string_option =
ui_token_amount.get("uiAmountString").and_then(|value| return value.as_str());
let ui_amount = match ui_amount_string_option {
Some(ui_amount_string) => {
let parse_result = ui_amount_string.parse::<f64>();
match parse_result {
Ok(ui_amount) => Some(ui_amount),
Err(_) => None,
}
},
None => None,
};
result.insert(account_address, (raw_amount, ui_amount));
}
return Ok(result);
}
fn compute_amount_delta_abs(
pre_amount: std::option::Option<std::string::String>,
post_amount: std::option::Option<std::string::String>,
) -> std::option::Option<std::string::String> {
let pre_amount = match pre_amount {
Some(pre_amount) => pre_amount,
None => "0".to_string(),
};
let post_amount = match post_amount {
Some(post_amount) => post_amount,
None => "0".to_string(),
};
let pre_value_result = pre_amount.parse::<i128>();
let pre_value = match pre_value_result {
Ok(pre_value) => pre_value,
Err(_) => return None,
};
let post_value_result = post_amount.parse::<i128>();
let post_value = match post_value_result {
Ok(post_value) => post_value,
Err(_) => return None,
};
let delta = if post_value >= pre_value {
post_value - pre_value
} else {
pre_value - post_value
};
return Some(delta.to_string());
}
fn compute_ui_delta_abs(
pre_amount: std::option::Option<f64>,
post_amount: std::option::Option<f64>,
) -> std::option::Option<f64> {
let pre_amount = match pre_amount {
Some(pre_amount) => pre_amount,
None => 0.0,
};
let post_amount = match post_amount {
Some(post_amount) => post_amount,
None => 0.0,
};
let delta = if post_amount >= pre_amount {
post_amount - pre_amount
} else {
pre_amount - post_amount
};
return Some(delta);
}
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 = crate::trade_solana_amounts::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 = crate::trade_solana_amounts::extract_string_by_candidate_keys(
nested_value,
candidate_keys,
);
if nested_result.is_some() {
return nested_result;
}
}
}
return None;
}
fn extract_scalar_as_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 {
if let Some(direct_text) = direct.as_str() {
return Some(direct_text.to_string());
}
if let Some(direct_u64) = direct.as_u64() {
return Some(direct_u64.to_string());
}
if let Some(direct_i64) = direct.as_i64() {
return Some(direct_i64.to_string());
}
}
}
for nested_value in object.values() {
let nested_result =
crate::trade_solana_amounts::extract_scalar_as_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 =
crate::trade_solana_amounts::extract_scalar_as_string_by_candidate_keys(
nested_value,
candidate_keys,
);
if nested_result.is_some() {
return nested_result;
}
}
}
return None;
}
#[cfg(test)]
mod tests {
#[test]
fn vault_balance_deltas_extract_raw_amounts_and_price() {
let transaction_json = serde_json::json!({
"transaction": {
"message": {
"accountKeys": [
"BaseVault111",
"QuoteVault111"
]
}
}
});
let meta_json = serde_json::json!({
"preTokenBalances": [
{
"accountIndex": 0,
"uiTokenAmount": {
"amount": "1000000",
"uiAmountString": "1.0"
}
},
{
"accountIndex": 1,
"uiTokenAmount": {
"amount": "2000000000",
"uiAmountString": "2.0"
}
}
],
"postTokenBalances": [
{
"accountIndex": 0,
"uiTokenAmount": {
"amount": "1500000",
"uiAmountString": "1.5"
}
},
{
"accountIndex": 1,
"uiTokenAmount": {
"amount": "1000000000",
"uiAmountString": "1.0"
}
}
]
});
let transaction_json_text = transaction_json.to_string();
let meta_json_text = meta_json.to_string();
let result = super::extract_trade_amounts_from_vault_balance_deltas(
transaction_json_text.as_str(),
Some(meta_json_text.as_str()),
Some("BaseVault111"),
Some("QuoteVault111"),
);
let amounts = match result {
Ok(amounts) => amounts,
Err(error) => panic!("vault delta extraction should succeed: {}", error),
};
assert_eq!(amounts.0, Some("500000".to_string()));
assert_eq!(amounts.1, Some("1000000000".to_string()));
assert_eq!(amounts.2, Some(2.0));
}
#[test]
fn instruction_transfer_amounts_follow_base_quote_vault_mapping() {
let meta_json = serde_json::json!({
"innerInstructions": [
{
"index": 3,
"instructions": [
{
"programId": crate::SPL_TOKEN_PROGRAM_ID,
"parsed": {
"type": "transfer",
"info": {
"source": "UserQuote111",
"destination": "QuoteVault111",
"amount": "2000000000"
}
}
},
{
"programId": crate::SPL_TOKEN_PROGRAM_ID,
"parsed": {
"type": "transfer",
"info": {
"source": "BaseVault111",
"destination": "UserBase111",
"amount": "500000"
}
}
}
]
}
]
});
let meta_json_text = meta_json.to_string();
let result = super::extract_trade_amounts_from_instruction_token_transfers(
Some(meta_json_text.as_str()),
Some(3),
Some("QuoteVault111"),
Some("BaseVault111"),
Some("UserQuote111"),
Some("UserBase111"),
Some("BaseVault111"),
Some("QuoteVault111"),
);
let amounts = match result {
Ok(amounts) => amounts,
Err(error) => panic!("instruction transfer extraction should succeed: {}", error),
};
assert_eq!(amounts.0, Some("500000".to_string()));
assert_eq!(amounts.1, Some("2000000000".to_string()));
assert_eq!(amounts.2, None);
}
#[test]
fn pump_fun_amounts_extract_token_delta_and_native_delta() {
let transaction_json = serde_json::json!({
"transaction": {
"message": {
"accountKeys": [
"BaseVault111",
"NativeQuote111"
]
}
}
});
let meta_json = serde_json::json!({
"preTokenBalances": [
{
"accountIndex": 0,
"uiTokenAmount": {
"amount": "1000000",
"uiAmountString": "1.0"
}
}
],
"postTokenBalances": [
{
"accountIndex": 0,
"uiTokenAmount": {
"amount": "1500000",
"uiAmountString": "1.5"
}
}
],
"preBalances": [
0,
3000000000u64
],
"postBalances": [
0,
2000000000u64
]
});
let transaction_json_text = transaction_json.to_string();
let meta_json_text = meta_json.to_string();
let result = super::extract_pump_fun_amounts_from_transaction(
transaction_json_text.as_str(),
Some(meta_json_text.as_str()),
Some("BaseVault111"),
Some("NativeQuote111"),
);
let amounts = match result {
Ok(amounts) => amounts,
Err(error) => panic!("pump_fun extraction should succeed: {}", error),
};
assert_eq!(amounts.0, Some("500000".to_string()));
assert_eq!(amounts.1, Some("1000000000".to_string()));
assert_eq!(amounts.2, Some(2.0));
}
}

View File

@@ -0,0 +1,466 @@
// file: kb_lib/src/transaction_classification.rs
//! Transaction classification service.
//!
//! This service classifies projected Solana transactions after transaction
//! projection and optional DEX decoding.
//!
//! The first version is intentionally deterministic and conservative:
//! decoded DEX events win over program-id hints, and unknown transactions are
//! preserved as explicit `unknown_or_unclassified` rows.
/// Service used to classify projected Solana transactions.
#[derive(Debug, Clone)]
pub struct TransactionClassificationService {
database: std::sync::Arc<crate::Database>,
}
impl TransactionClassificationService {
/// Creates a transaction classification service.
pub fn new(database: std::sync::Arc<crate::Database>) -> Self {
return Self { database };
}
/// Classifies one transaction by signature and persists the classification.
pub async fn classify_transaction_by_signature(
&self,
signature: &str,
) -> Result<crate::TransactionClassificationDto, crate::Error> {
let context_result =
load_transaction_classification_context(self.database.as_ref(), signature).await;
let context = match context_result {
Ok(context) => context,
Err(error) => return Err(error),
};
let classification = classify_transaction_context(&context);
let dto = crate::TransactionClassificationDto::new(
context.transaction_id,
context.transaction.signature.clone(),
context.transaction.slot,
classification.kind.to_string(),
classification.primary_protocol,
classification.primary_program_id,
classification.confidence_level,
classification.reason,
classification.evidence_json,
);
let upsert_result =
crate::query_transaction_classifications_upsert(self.database.as_ref(), &dto).await;
if let Err(error) = upsert_result {
return Err(error);
}
let persisted_result = crate::query_transaction_classifications_get_by_transaction_id(
self.database.as_ref(),
context.transaction_id,
)
.await;
let persisted_option = match persisted_result {
Ok(persisted_option) => persisted_option,
Err(error) => return Err(error),
};
let persisted = match persisted_option {
Some(persisted) => persisted,
None => {
return Err(crate::Error::InvalidState(format!(
"transaction classification for '{}' disappeared after upsert",
signature
)));
},
};
let candidate_recording_result =
crate::protocol_candidate_recording::record_protocol_candidates_for_classification(
crate::protocol_candidate_recording::ProtocolCandidateRecordingInput {
database: self.database.as_ref(),
transaction: &context.transaction,
transaction_id: context.transaction_id,
instructions: &context.instructions,
classification_kind: persisted.classification_kind.as_str(),
},
)
.await;
match candidate_recording_result {
Ok(candidate_count) => {
tracing::trace!(
signature = %context.transaction.signature,
classification_kind = %persisted.classification_kind,
protocol_candidate_count = candidate_count,
"transaction protocol candidates recorded"
);
},
Err(error) => return Err(error),
}
return Ok(persisted);
}
}
struct TransactionClassificationContext {
transaction: crate::ChainTransactionDto,
transaction_id: i64,
instructions: std::vec::Vec<crate::ChainInstructionDto>,
decoded_events: std::vec::Vec<crate::DexDecodedEventDto>,
}
struct TransactionClassificationDecision {
kind: &'static str,
primary_protocol: std::option::Option<std::string::String>,
primary_program_id: std::option::Option<std::string::String>,
confidence_level: i16,
reason: std::string::String,
evidence_json: std::string::String,
}
#[derive(Debug, Clone)]
struct KnownDexProgramMatch {
protocol_name: &'static str,
program_id: std::string::String,
instruction_id: std::option::Option<i64>,
instruction_index: u32,
}
async fn load_transaction_classification_context(
database: &crate::Database,
signature: &str,
) -> Result<TransactionClassificationContext, crate::Error> {
let transaction_result =
crate::query_chain_transactions_get_by_signature(database, signature).await;
let transaction_option = match transaction_result {
Ok(transaction_option) => transaction_option,
Err(error) => return Err(error),
};
let transaction = match transaction_option {
Some(transaction) => transaction,
None => {
return Err(crate::Error::InvalidState(format!(
"cannot classify unknown chain transaction '{}'",
signature
)));
},
};
let transaction_id = match transaction.id {
Some(transaction_id) => transaction_id,
None => {
return Err(crate::Error::InvalidState(format!(
"chain transaction '{}' has no internal id",
signature
)));
},
};
let instructions_result =
crate::query_chain_instructions_list_by_transaction_id(database, transaction_id).await;
let instructions = match instructions_result {
Ok(instructions) => instructions,
Err(error) => return Err(error),
};
let decoded_events_result =
crate::query_dex_decoded_events_list_by_transaction_id(database, transaction_id).await;
let decoded_events = match decoded_events_result {
Ok(decoded_events) => decoded_events,
Err(error) => return Err(error),
};
return Ok(TransactionClassificationContext {
transaction,
transaction_id,
instructions,
decoded_events,
});
}
fn classify_transaction_context(
context: &TransactionClassificationContext,
) -> TransactionClassificationDecision {
if !context.decoded_events.is_empty() {
return classify_from_decoded_events(context);
}
let known_program_matches = find_known_dex_program_matches(&context.instructions);
if !known_program_matches.is_empty() {
return classify_from_known_program_matches(context, &known_program_matches);
}
return build_decision(
"unknown_or_unclassified",
None,
None,
25,
"transaction has no decoded DEX event and no known DEX program id".to_string(),
serde_json::json!({
"transactionId": context.transaction_id,
"signature": context.transaction.signature,
"slot": context.transaction.slot,
"instructionCount": context.instructions.len(),
"decodedEventCount": context.decoded_events.len()
}),
);
}
fn classify_from_decoded_events(
context: &TransactionClassificationContext,
) -> TransactionClassificationDecision {
let mut first_protocol = None;
let mut first_program_id = None;
let mut trade_event_count = 0_i64;
let mut non_trade_event_count = 0_i64;
let mut decoded_event_evidence = std::vec::Vec::new();
for decoded_event in &context.decoded_events {
if first_protocol.is_none() {
first_protocol = Some(decoded_event.protocol_name.clone());
}
if first_program_id.is_none() {
first_program_id = Some(decoded_event.program_id.clone());
}
let payload_value_result =
serde_json::from_str::<serde_json::Value>(decoded_event.payload_json.as_str());
let payload_value = match payload_value_result {
Ok(payload_value) => payload_value,
Err(_) => serde_json::Value::Null,
};
let is_trade = crate::is_decoded_event_trade_candidate(
decoded_event.event_kind.as_str(),
&payload_value,
);
if is_trade {
trade_event_count += 1;
} else {
non_trade_event_count += 1;
}
decoded_event_evidence.push(serde_json::json!({
"id": decoded_event.id,
"protocolName": decoded_event.protocol_name,
"programId": decoded_event.program_id,
"eventKind": decoded_event.event_kind,
"poolAccount": decoded_event.pool_account,
"tradeCandidate": is_trade
}));
}
if trade_event_count > 0_i64 {
return build_decision(
"dex_trade",
first_protocol,
first_program_id,
100,
"transaction has at least one decoded DEX trade event".to_string(),
serde_json::json!({
"transactionId": context.transaction_id,
"signature": context.transaction.signature,
"slot": context.transaction.slot,
"decodedEventCount": context.decoded_events.len(),
"tradeEventCount": trade_event_count,
"nonTradeEventCount": non_trade_event_count,
"decodedEvents": decoded_event_evidence
}),
);
}
return build_decision(
"dex_non_trade",
first_protocol,
first_program_id,
95,
"transaction has decoded DEX events but no trade candidate".to_string(),
serde_json::json!({
"transactionId": context.transaction_id,
"signature": context.transaction.signature,
"slot": context.transaction.slot,
"decodedEventCount": context.decoded_events.len(),
"tradeEventCount": trade_event_count,
"nonTradeEventCount": non_trade_event_count,
"decodedEvents": decoded_event_evidence
}),
);
}
fn classify_from_known_program_matches(
context: &TransactionClassificationContext,
known_program_matches: &[KnownDexProgramMatch],
) -> TransactionClassificationDecision {
let first_match = &known_program_matches[0];
let mut evidence_items = std::vec::Vec::new();
for known_program_match in known_program_matches {
evidence_items.push(serde_json::json!({
"protocolName": known_program_match.protocol_name,
"programId": known_program_match.program_id,
"instructionId": known_program_match.instruction_id,
"instructionIndex": known_program_match.instruction_index
}));
}
return build_decision(
"known_dex_program_unclassified",
Some(first_match.protocol_name.to_string()),
Some(first_match.program_id.to_string()),
75,
"transaction has known DEX program instructions but no decoded DEX event".to_string(),
serde_json::json!({
"transactionId": context.transaction_id,
"signature": context.transaction.signature,
"slot": context.transaction.slot,
"instructionCount": context.instructions.len(),
"decodedEventCount": context.decoded_events.len(),
"knownDexProgramMatches": evidence_items
}),
);
}
fn build_decision(
kind: &'static str,
primary_protocol: std::option::Option<std::string::String>,
primary_program_id: std::option::Option<std::string::String>,
confidence_level: i16,
reason: std::string::String,
evidence_value: serde_json::Value,
) -> TransactionClassificationDecision {
let evidence_json_result = serde_json::to_string(&evidence_value);
let evidence_json = match evidence_json_result {
Ok(evidence_json) => evidence_json,
Err(error) => {
return TransactionClassificationDecision {
kind: "unknown_or_unclassified",
primary_protocol: None,
primary_program_id: None,
confidence_level: 0,
reason: format!("cannot serialize classification evidence: {}", error),
evidence_json: "{}".to_string(),
};
},
};
return TransactionClassificationDecision {
kind,
primary_protocol,
primary_program_id,
confidence_level,
reason,
evidence_json,
};
}
fn find_known_dex_program_matches(
instructions: &[crate::ChainInstructionDto],
) -> std::vec::Vec<KnownDexProgramMatch> {
let mut matches = std::vec::Vec::new();
for instruction in instructions {
let program_match = known_dex_program_match(instruction);
let program_match = match program_match {
Some(program_match) => program_match,
None => continue,
};
matches.push(program_match);
}
return matches;
}
fn known_dex_program_match(
instruction: &crate::ChainInstructionDto,
) -> std::option::Option<KnownDexProgramMatch> {
let program_id = match instruction.program_id.as_deref() {
Some(program_id) => program_id,
None => return None,
};
let protocol_name = if program_id == crate::RAYDIUM_AMM_V4_PROGRAM_ID {
"raydium_amm_v4"
} else if program_id == crate::RAYDIUM_CPMM_PROGRAM_ID {
"raydium_cpmm"
} else if program_id == crate::RAYDIUM_CLMM_PROGRAM_ID {
"raydium_clmm"
} else if program_id == crate::RAYDIUM_LAUNCHLAB_PROGRAM_ID {
"raydium_launchlab"
} else if program_id == crate::RAYDIUM_AMM_ROUTING_PROGRAM_ID {
"raydium_router"
} else if program_id == crate::RAYDIUM_STABLE_SWAP_AMM_PROGRAM_ID {
"raydium_stable_swap"
} else if program_id == crate::PUMP_FUN_PROGRAM_ID {
"pump_fun"
} else if program_id == crate::PUMP_SWAP_PROGRAM_ID {
"pump_swap"
} else if program_id == crate::METEORA_DBC_PROGRAM_ID {
"meteora_dbc"
} else if program_id == crate::METEORA_DLMM_PROGRAM_ID {
"meteora_dlmm"
} else if program_id == crate::METEORA_DAMM_V1_PROGRAM_ID {
"meteora_damm_v1"
} else if program_id == crate::METEORA_DAMM_V2_PROGRAM_ID {
"meteora_damm_v2"
} else if program_id == crate::ORCA_WHIRLPOOLS_PROGRAM_ID {
"orca_whirlpools"
} else if program_id == crate::FLUXBEAM_PROGRAM_ID {
"fluxbeam"
} else if program_id == crate::DEXLAB_PROGRAM_ID {
"dexlab"
} else {
return None;
};
return Some(KnownDexProgramMatch {
protocol_name,
program_id: program_id.to_string(),
instruction_id: instruction.id,
instruction_index: instruction.instruction_index,
});
}
#[cfg(test)]
mod tests {
fn test_instruction(
program_id: std::option::Option<std::string::String>,
) -> crate::ChainInstructionDto {
return crate::ChainInstructionDto::new(
1,
None,
0,
None,
program_id,
None,
None,
"[]".to_string(),
None,
None,
Some(serde_json::json!({}).to_string()),
);
}
fn test_transaction() -> crate::ChainTransactionDto {
let mut transaction = crate::ChainTransactionDto::new(
"signature_1".to_string(),
Some(123),
None,
Some("test".to_string()),
None,
None,
None,
serde_json::json!({}).to_string(),
);
transaction.id = Some(1);
return transaction;
}
#[test]
fn known_dex_program_ids_are_matched() {
let instruction = test_instruction(Some(crate::RAYDIUM_CPMM_PROGRAM_ID.to_string()));
let program_match = match super::known_dex_program_match(&instruction) {
Some(program_match) => program_match,
None => {
panic!("expected raydium_cpmm program match");
},
};
assert_eq!(program_match.protocol_name, "raydium_cpmm");
assert_eq!(program_match.program_id, crate::RAYDIUM_CPMM_PROGRAM_ID);
assert_eq!(program_match.instruction_index, 0);
}
#[test]
fn unknown_program_id_is_not_matched() {
let instruction =
test_instruction(Some("UnknownProgram111111111111111111111111111111111".to_string()));
let program_match = super::known_dex_program_match(&instruction);
assert!(program_match.is_none());
}
#[test]
fn unknown_context_is_classified_as_unknown_or_unclassified() {
let transaction = test_transaction();
let context = super::TransactionClassificationContext {
transaction,
transaction_id: 1,
instructions: std::vec::Vec::new(),
decoded_events: std::vec::Vec::new(),
};
let decision = super::classify_transaction_context(&context);
assert_eq!(decision.kind, "unknown_or_unclassified");
assert_eq!(decision.confidence_level, 25);
}
}

View File

@@ -109,6 +109,7 @@ pub struct TransactionResolutionService {
wallet_holding_observation_service: crate::WalletHoldingObservationService,
pair_candle_aggregation_service: crate::PairCandleAggregationService,
pair_analytic_signal_service: crate::PairAnalyticSignalService,
transaction_classification_service: crate::TransactionClassificationService,
resolved_signatures:
std::sync::Arc<tokio::sync::Mutex<std::collections::HashSet<std::string::String>>>,
}
@@ -133,6 +134,8 @@ impl TransactionResolutionService {
let pair_candle_aggregation_service =
crate::PairCandleAggregationService::new(database.clone());
let pair_analytic_signal_service = crate::PairAnalyticSignalService::new(database.clone());
let transaction_classification_service =
crate::TransactionClassificationService::new(database.clone());
return Self {
http_pool,
persistence,
@@ -147,6 +150,7 @@ impl TransactionResolutionService {
wallet_holding_observation_service,
pair_candle_aggregation_service,
pair_analytic_signal_service,
transaction_classification_service,
resolved_signatures: std::sync::Arc::new(tokio::sync::Mutex::new(
std::collections::HashSet::new(),
)),
@@ -400,6 +404,17 @@ impl TransactionResolutionService {
Err(error) => return Err(error),
};
let pair_analytic_signal_count = pair_analytic_signals.len();
let transaction_classification_result = self
.transaction_classification_service
.classify_transaction_by_signature(request.signature.as_str())
.await;
let transaction_classification = match transaction_classification_result {
Ok(transaction_classification) => transaction_classification,
Err(error) => return Err(error),
};
let transaction_classification_id = transaction_classification.id;
let transaction_classification_kind =
transaction_classification.classification_kind.clone();
let payload = serde_json::json!({
"status": "resolved",
"signature": request.signature.clone(),
@@ -417,6 +432,8 @@ impl TransactionResolutionService {
"tradeEventCount": trade_event_count,
"pairCandleCount": pair_candle_count,
"pairAnalyticSignalCount": pair_analytic_signal_count,
"transactionClassificationId": transaction_classification_id,
"transactionClassificationKind": transaction_classification_kind,
"transaction": transaction_value
});
let observation_id_result = self