diff --git a/CHANGELOG.md b/CHANGELOG.md index 9540128..d231b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,3 +40,4 @@ 0.7.7 - Ajout du premier support Meteora DAMM v2 avec décodage create_pool/swap, persistance des événements décodés et détection métier automatique pool/pair/listing 0.7.8 - Ajout du premier support Meteora DAMM v1 avec décodage create_pool/swap, persistance des événements décodés et détection métier automatique pool/pair/listing 0.7.9 - Ajout d’un registre des surfaces de lancement, d’une attribution automatique des pools détectés à une origine de lancement, et d’un premier support Meteora Fun Launch au-dessus de Meteora DBC / DAMM +0.7.10 - Ajout du premier support Orca Whirlpools avec décodage create_pool/swap, persistance des événements décodés et détection métier automatique pool/pair/listing diff --git a/Cargo.toml b/Cargo.toml index efa40f0..c990b84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.7.9" +version = "0.7.10" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-bobobot" diff --git a/ROADMAP.md b/ROADMAP.md index 317c967..e897891 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -547,7 +547,50 @@ Réalisé : - branchement automatique de l’attribution depuis le pipeline de résolution transactionnelle, - conservation d’une séparation stricte entre protocole on-chain et origine de lancement. -### 6.042. Version `0.7.10` — Consolidation multi-DEX +### 6.042. Version `0.7.10` — Orca / Whirlpools +Réalisé : + +- ajout du premier décodeur `Orca Whirlpools`, +- prise en charge initiale des événements de création de pool via `initialize_pool` et `initialize_pool_v2`, +- prise en charge initiale des swaps via `swap` et `swap_v2`, +- persistance des événements `Orca Whirlpools` dans `kb_dex_decoded_events`, +- ajout de la détection métier `Orca Whirlpools` vers `pool / pair / listing`, +- utilisation de `KbPoolKind::Clmm` pour refléter la nature concentrée de `Whirlpools`. + +### 6.043. Version `0.7.11` — FluxBeam +Objectif : couvrir un connecteur DEX supplémentaire orienté pools et listings Solana. + +À faire : + +- ajouter un décodeur dédié `FluxBeam`, +- identifier les événements utiles de création de pool et de swaps, +- persister les événements décodés dans `kb_dex_decoded_events`, +- ajouter la détection métier `pool / pair / listing` pour `FluxBeam`, +- préparer les variations éventuelles liées aux tokens étendus. + +### 6.044. Version `0.7.12` — DexLab +Objectif : couvrir un connecteur DEX / LP supplémentaire utile à la détection de listings. + +À faire : + +- ajouter un décodeur dédié `DexLab`, +- identifier les événements utiles de création de pool et de swaps, +- persister les événements décodés dans `kb_dex_decoded_events`, +- ajouter la détection métier `pool / pair / listing` pour `DexLab`, +- conserver un découpage explicite entre protocole on-chain et surfaces d’émission. + +### 6.045. Version `0.7.13` — Bags / Moonit comme origines de lancement +Objectif : étendre la couche `launch origins` à d’autres surfaces au-dessus des protocoles DEX déjà intégrés. + +À faire : + +- ajouter `Bags` comme surface de lancement détectable, +- ajouter `Moonit` comme surface de lancement détectable, +- relier ces surfaces aux pools et paires finalement créés, +- conserver une séparation stricte entre origine de lancement et protocole on-chain, +- préparer l’extension future à d’autres launchpads ou surfaces dérivées. + +### 6.046. Version `0.7.14` — Consolidation multi-DEX Objectif : unifier le comportement des connecteurs DEX v1 avant l’ouverture des couches analytiques plus riches. À faire : @@ -558,7 +601,7 @@ Objectif : unifier le comportement des connecteurs DEX v1 avant l’ouverture de - améliorer l’idempotence et la traçabilité inter-protocoles, - préparer la base des futurs événements enrichis de liquidité, swaps et activité. -### 6.043. Version `0.7.11` — Wallets, holdings et participants observés +### 6.047. Version `0.7.15` — Wallets, holdings et participants observés Objectif : préparer le suivi des acteurs on-chain autour des pools et tokens détectés. À faire : @@ -568,7 +611,7 @@ Objectif : préparer le suivi des acteurs on-chain autour des pools et tokens d - préparer l’identification des créateurs, mint authorities, wallets d’activité et contreparties, - éviter de limiter l’analyse future au seul niveau token/pool sans vision des participants. -### 6.044. Version `0.7.12` — Séries de prix, volumes et agrégats DEX +### 6.048. Version `0.7.16` — Séries de prix, volumes et agrégats DEX Objectif : préparer la couche analytique fine à partir des événements métier normalisés. À faire : @@ -578,7 +621,7 @@ Objectif : préparer la couche analytique fine à partir des événements métie - permettre plus tard le calcul d’OHLCV, volume, nombre de trades et liquidité par fenêtre, - préparer le terrain pour la couche analytique `0.8.x`. -### 6.045. Version `0.7.x` — Couverture DEX v1 +### 6.049. Version `0.7.x` — Couverture DEX v1 Objectif : structurer les connecteurs DEX autour d’un pipeline complet de résolution, décodage et normalisation métier. Protocoles cibles : @@ -593,10 +636,8 @@ Protocoles cibles : - Orca - Bags - FluxBeam -- Heaven - DexLab - Moonit -- Zora Résultat attendu : @@ -606,7 +647,7 @@ Résultat attendu : - création d’objets métier riches pour tokens, pools, paires, listings et participants, - remplacement progressif des scripts heuristiques externes par des composants Rust intégrés. -### 6.046. Version `0.8.x` — Analyse et filtrage +### 6.050. Version `0.8.x` — Analyse et filtrage Objectif : transformer les événements bruts en signaux exploitables. À faire : @@ -617,7 +658,7 @@ Objectif : transformer les événements bruts en signaux exploitables. - statistiques de comportement, - premiers patterns. -### 6.047. Version `1.x.y` — Wallets et swap préparatoire +### 6.051. Version `1.x.y` — Wallets et swap préparatoire Objectif : préparer la couche d’action. À faire : @@ -628,7 +669,7 @@ Objectif : préparer la couche d’action. - préparation d’ordres et de swaps, - simulation et garde-fous. -### 6.048. Version `2.x.y` — Trading semi-automatisé +### 6.052. Version `2.x.y` — Trading semi-automatisé Objectif : brancher l’analyse à l’action tout en gardant des garde-fous explicites. À faire : @@ -639,7 +680,7 @@ Objectif : brancher l’analyse à l’action tout en gardant des garde-fous exp - confirmations explicites ou semi-automatiques, - journaux d’exécution. -### 6.049. Version `3.x.y` — Yellowstone gRPC +### 6.053. Version `3.x.y` — Yellowstone gRPC Objectif : ajouter le connecteur gRPC dédié. À faire : @@ -727,9 +768,9 @@ Le projet doit maintenir au minimum : La priorité immédiate est désormais la suivante : -1. démarrer la version `0.7.7` avec le premier support `Meteora DAMM v2`, -2. préparer le rattachement futur entre `Meteora DBC` et `Meteora DAMM v2`, -3. conserver un décodeur séparé par protocole et par version, -4. préparer ensuite la version `0.7.8` pour `Meteora DAMM v1`, -5. préparer ensuite la version `0.7.9` pour `LaunchLab / Fun Launch`, -6. garder l’unification multi-DEX et la consolidation métier pour `0.7.10`. +1. démarrer la version `0.7.10` avec le premier support `Orca / Whirlpools`, +2. conserver un décodeur séparé par protocole et par version, +3. préparer ensuite la version `0.7.11` pour `FluxBeam`, +4. préparer ensuite la version `0.7.12` pour `DexLab`, +5. étendre ensuite la couche `launch origins` à `Bags` et `Moonit`, +6. garder l’unification multi-DEX et la consolidation métier pour `0.7.14`. diff --git a/kb_lib/src/dex.rs b/kb_lib/src/dex.rs index 15788f6..f655583 100644 --- a/kb_lib/src/dex.rs +++ b/kb_lib/src/dex.rs @@ -5,6 +5,7 @@ mod meteora_damm_v1; mod meteora_damm_v2; mod meteora_dbc; +mod orca_whirlpools; mod pump_fun; mod pump_swap; mod raydium_amm_v4; @@ -27,6 +28,12 @@ pub use meteora_dbc::KbMeteoraDbcDecodedEvent; pub use meteora_dbc::KbMeteoraDbcDecoder; pub use meteora_dbc::KbMeteoraDbcSwapDecoded; +pub use orca_whirlpools::KB_ORCA_WHIRLPOOLS_PROGRAM_ID; +pub use orca_whirlpools::KbOrcaWhirlpoolsCreatePoolDecoded; +pub use orca_whirlpools::KbOrcaWhirlpoolsDecodedEvent; +pub use orca_whirlpools::KbOrcaWhirlpoolsDecoder; +pub use orca_whirlpools::KbOrcaWhirlpoolsSwapDecoded; + pub use pump_fun::KB_PUMP_FUN_PROGRAM_ID; pub use pump_fun::KbPumpFunCreateV2TokenDecoded; pub use pump_fun::KbPumpFunDecodedEvent; diff --git a/kb_lib/src/dex/orca_whirlpools.rs b/kb_lib/src/dex/orca_whirlpools.rs new file mode 100644 index 0000000..72afad6 --- /dev/null +++ b/kb_lib/src/dex/orca_whirlpools.rs @@ -0,0 +1,720 @@ +// file: kb_lib/src/dex/orca_whirlpools.rs + +//! Orca Whirlpools transaction decoder. + +/// Orca Whirlpools program id. +pub const KB_ORCA_WHIRLPOOLS_PROGRAM_ID: &str = + "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"; + +/// Decoded Orca Whirlpools create-pool event. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct KbOrcaWhirlpoolsCreatePoolDecoded { + /// Parent transaction id. + pub transaction_id: i64, + /// Parent instruction id. + pub instruction_id: i64, + /// Transaction signature. + pub signature: std::string::String, + /// Program id. + pub program_id: std::string::String, + /// Optional whirlpool account. + pub pool_account: std::option::Option, + /// Optional token A mint. + pub token_a_mint: std::option::Option, + /// Optional token B mint. + pub token_b_mint: std::option::Option, + /// Optional whirlpools config account. + pub config_account: std::option::Option, + /// Optional creator / funder. + pub creator: std::option::Option, + /// Whether the instruction looked like `initialize_pool_v2`. + pub used_v2: bool, + /// Decoded payload. + pub payload_json: serde_json::Value, +} + +/// Decoded Orca Whirlpools swap event. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct KbOrcaWhirlpoolsSwapDecoded { + /// Parent transaction id. + pub transaction_id: i64, + /// Parent instruction id. + pub instruction_id: i64, + /// Transaction signature. + pub signature: std::string::String, + /// Program id. + pub program_id: std::string::String, + /// Trade side relative to normalized base. + pub trade_side: crate::KbSwapTradeSide, + /// Optional whirlpool account. + pub pool_account: std::option::Option, + /// Optional token A mint. + pub token_a_mint: std::option::Option, + /// Optional token B mint. + pub token_b_mint: std::option::Option, + /// Whether the instruction looked like `swap_v2`. + pub used_v2: bool, + /// Decoded payload. + pub payload_json: serde_json::Value, +} + +/// Decoded Orca Whirlpools event. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum KbOrcaWhirlpoolsDecodedEvent { + /// Pool creation. + CreatePool(KbOrcaWhirlpoolsCreatePoolDecoded), + /// Swap / swap_v2. + Swap(KbOrcaWhirlpoolsSwapDecoded), +} + +/// Orca Whirlpools decoder. +#[derive(Debug, Clone, Default)] +pub struct KbOrcaWhirlpoolsDecoder; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KbOrcaWhirlpoolsInstructionKind { + InitializePool, + InitializePoolV2, + Swap, + SwapV2, + Unknown, +} + +impl KbOrcaWhirlpoolsDecoder { + /// Creates a new decoder. + pub fn new() -> Self { + Self + } + + /// Decodes one projected transaction into zero or more Orca Whirlpools events. + pub fn decode_transaction( + &self, + transaction: &crate::KbChainTransactionDto, + instructions: &[crate::KbChainInstructionDto], + ) -> Result, crate::KbError> { + let transaction_id_option = transaction.id; + let transaction_id = match transaction_id_option { + Some(transaction_id) => transaction_id, + None => { + return Err(crate::KbError::InvalidState(format!( + "chain transaction '{}' has no internal id", + transaction.signature + ))); + } + }; + let transaction_json_result = + serde_json::from_str::(transaction.transaction_json.as_str()); + let transaction_json = match transaction_json_result { + Ok(transaction_json) => transaction_json, + Err(error) => { + return Err(crate::KbError::Json(format!( + "cannot parse transaction_json for signature '{}': {}", + transaction.signature, error + ))); + } + }; + let log_messages = kb_extract_log_messages(&transaction_json); + let mut decoded_events = std::vec::Vec::new(); + for instruction in instructions { + if instruction.parent_instruction_id.is_some() { + continue; + } + let program_id_option = &instruction.program_id; + let program_id = match program_id_option { + Some(program_id) => program_id, + None => continue, + }; + if program_id.as_str() != crate::KB_ORCA_WHIRLPOOLS_PROGRAM_ID { + continue; + } + let instruction_id_option = instruction.id; + let instruction_id = match instruction_id_option { + Some(instruction_id) => instruction_id, + None => continue, + }; + let accounts_result = kb_parse_accounts_json(instruction.accounts_json.as_str()); + let accounts = match accounts_result { + Ok(accounts) => accounts, + Err(error) => return Err(error), + }; + let parsed_json_result = kb_parse_optional_parsed_json(instruction.parsed_json.as_ref()); + let parsed_json = match parsed_json_result { + Ok(parsed_json) => parsed_json, + Err(error) => return Err(error), + }; + let instruction_kind = + kb_classify_instruction_kind(parsed_json.as_ref(), &log_messages); + let pool_account = kb_extract_string_by_candidate_keys( + parsed_json.as_ref(), + &[ + "whirlpool", + "pool", + "poolAddress", + "poolAccount", + "whirlpoolAddress", + ], + ) + .or_else(|| kb_extract_account(&accounts, 0)); + let token_a_mint = kb_extract_string_by_candidate_keys( + parsed_json.as_ref(), + &[ + "tokenMintA", + "tokenAMint", + "mintA", + "baseMint", + "token0Mint", + "mint0", + ], + ) + .or_else(|| kb_extract_account(&accounts, 1)); + let token_b_mint = kb_extract_string_by_candidate_keys( + parsed_json.as_ref(), + &[ + "tokenMintB", + "tokenBMint", + "mintB", + "quoteMint", + "token1Mint", + "mint1", + ], + ) + .or_else(|| kb_extract_account(&accounts, 2)); + let config_account = kb_extract_string_by_candidate_keys( + parsed_json.as_ref(), + &[ + "whirlpoolsConfig", + "config", + "configAccount", + "whirlpoolConfig", + ], + ) + .or_else(|| kb_extract_account(&accounts, 3)); + let creator = kb_extract_string_by_candidate_keys( + parsed_json.as_ref(), + &["funder", "creator", "payer", "user", "owner"], + ) + .or_else(|| kb_extract_account(&accounts, 4)); + if instruction_kind == KbOrcaWhirlpoolsInstructionKind::InitializePool + || instruction_kind == KbOrcaWhirlpoolsInstructionKind::InitializePoolV2 + { + let used_v2 = + instruction_kind == KbOrcaWhirlpoolsInstructionKind::InitializePoolV2; + let payload_json = serde_json::json!({ + "decoder": "orca_whirlpools", + "eventKind": "create_pool", + "classifiedInstructionKind": if used_v2 { "initialize_pool_v2" } else { "initialize_pool" }, + "signature": transaction.signature, + "instructionId": instruction_id, + "instructionIndex": instruction.instruction_index, + "accounts": accounts, + "parsed": parsed_json, + "logMessages": log_messages, + "poolAccount": pool_account, + "tokenAMint": token_a_mint, + "tokenBMint": token_b_mint, + "configAccount": config_account, + "creator": creator + }); + decoded_events.push(crate::KbOrcaWhirlpoolsDecodedEvent::CreatePool( + crate::KbOrcaWhirlpoolsCreatePoolDecoded { + transaction_id, + instruction_id, + signature: transaction.signature.clone(), + program_id: program_id.clone(), + pool_account, + token_a_mint, + token_b_mint, + config_account, + creator, + used_v2, + payload_json, + }, + )); + continue; + } + if instruction_kind == KbOrcaWhirlpoolsInstructionKind::Swap + || instruction_kind == KbOrcaWhirlpoolsInstructionKind::SwapV2 + { + let used_v2 = instruction_kind == KbOrcaWhirlpoolsInstructionKind::SwapV2; + let trade_side = kb_infer_trade_side(&log_messages); + let payload_json = serde_json::json!({ + "decoder": "orca_whirlpools", + "eventKind": "swap", + "classifiedInstructionKind": if used_v2 { "swap_v2" } else { "swap" }, + "signature": transaction.signature, + "instructionId": instruction_id, + "instructionIndex": instruction.instruction_index, + "accounts": accounts, + "parsed": parsed_json, + "logMessages": log_messages, + "poolAccount": pool_account, + "tokenAMint": token_a_mint, + "tokenBMint": token_b_mint, + "tradeSide": format!("{:?}", trade_side) + }); + decoded_events.push(crate::KbOrcaWhirlpoolsDecodedEvent::Swap( + crate::KbOrcaWhirlpoolsSwapDecoded { + transaction_id, + instruction_id, + signature: transaction.signature.clone(), + program_id: program_id.clone(), + trade_side, + pool_account, + token_a_mint, + token_b_mint, + used_v2, + payload_json, + }, + )); + } + } + Ok(decoded_events) + } +} + +fn kb_classify_instruction_kind( + parsed_json: std::option::Option<&serde_json::Value>, + log_messages: &[std::string::String], +) -> KbOrcaWhirlpoolsInstructionKind { + let parsed_instruction_name = kb_extract_string_by_candidate_keys( + parsed_json, + &["instruction", "instructionName", "type", "name"], + ); + if let Some(parsed_instruction_name) = parsed_instruction_name { + let normalized = kb_normalize_text(parsed_instruction_name.as_str()); + if normalized.contains("initializepoolv2") { + return KbOrcaWhirlpoolsInstructionKind::InitializePoolV2; + } + if normalized.contains("initializepool") { + return KbOrcaWhirlpoolsInstructionKind::InitializePool; + } + if normalized == "swapv2" { + return KbOrcaWhirlpoolsInstructionKind::SwapV2; + } + if normalized == "swap" { + return KbOrcaWhirlpoolsInstructionKind::Swap; + } + } + if kb_value_contains_any_key( + parsed_json, + &["tokenProgramA", "tokenProgramB", "memoProgram"], + ) && kb_log_messages_contain_keyword(log_messages, "initialize_pool") + { + return KbOrcaWhirlpoolsInstructionKind::InitializePoolV2; + } + if kb_value_contains_any_key( + parsed_json, + &["tokenProgramA", "tokenProgramB", "memoProgram"], + ) && kb_log_messages_contain_keyword(log_messages, "swap") + { + return KbOrcaWhirlpoolsInstructionKind::SwapV2; + } + if kb_log_messages_contain_keyword(log_messages, "initialize_pool_v2") + || kb_log_messages_contain_keyword(log_messages, "initializepoolv2") + { + return KbOrcaWhirlpoolsInstructionKind::InitializePoolV2; + } + if kb_log_messages_contain_keyword(log_messages, "initialize_pool") + || kb_log_messages_contain_keyword(log_messages, "initializepool") + { + return KbOrcaWhirlpoolsInstructionKind::InitializePool; + } + if kb_log_messages_contain_keyword(log_messages, "swap_v2") + || kb_log_messages_contain_keyword(log_messages, "swapv2") + { + return KbOrcaWhirlpoolsInstructionKind::SwapV2; + } + if kb_log_messages_contain_keyword(log_messages, "swap") { + return KbOrcaWhirlpoolsInstructionKind::Swap; + } + KbOrcaWhirlpoolsInstructionKind::Unknown +} + +fn kb_extract_log_messages( + transaction_json: &serde_json::Value, +) -> std::vec::Vec { + let mut messages = std::vec::Vec::new(); + let meta_option = transaction_json.get("meta"); + let meta = match meta_option { + Some(meta) => meta, + None => return messages, + }; + let logs_option = meta.get("logMessages"); + let logs = match logs_option { + Some(logs) => logs, + None => return messages, + }; + let logs_array_option = logs.as_array(); + let logs_array = match logs_array_option { + Some(logs_array) => logs_array, + None => return messages, + }; + for value in logs_array { + let text_option = value.as_str(); + if let Some(text) = text_option { + messages.push(text.to_string()); + } + } + messages +} + +fn kb_log_messages_contain_keyword( + log_messages: &[std::string::String], + keyword: &str, +) -> bool { + let keyword_normalized = kb_normalize_text(keyword); + for log_message in log_messages { + let log_normalized = kb_normalize_text(log_message.as_str()); + if log_normalized.contains(keyword_normalized.as_str()) { + return true; + } + } + false +} + +fn kb_normalize_text(value: &str) -> std::string::String { + let mut normalized = std::string::String::new(); + for character in value.chars() { + if character.is_ascii_alphanumeric() { + normalized.push(character.to_ascii_lowercase()); + } + } + normalized +} + +fn kb_parse_accounts_json( + accounts_json: &str, +) -> Result, crate::KbError> { + let values_result = serde_json::from_str::>(accounts_json); + let values = match values_result { + Ok(values) => values, + Err(error) => { + return Err(crate::KbError::Json(format!( + "cannot parse instruction accounts_json '{}': {}", + accounts_json, error + ))); + } + }; + let mut accounts = std::vec::Vec::new(); + for value in values { + let text_option = value.as_str(); + if let Some(text) = text_option { + accounts.push(text.to_string()); + } + } + Ok(accounts) +} + +fn kb_parse_optional_parsed_json( + parsed_json: std::option::Option<&std::string::String>, +) -> Result, crate::KbError> { + let parsed_json = match parsed_json { + Some(parsed_json) => parsed_json, + None => return Ok(None), + }; + let value_result = serde_json::from_str::(parsed_json.as_str()); + match value_result { + Ok(value) => Ok(Some(value)), + Err(error) => Err(crate::KbError::Json(format!( + "cannot parse instruction parsed_json '{}': {}", + parsed_json, error + ))), + } +} + +fn kb_extract_string_by_candidate_keys( + value: std::option::Option<&serde_json::Value>, + candidate_keys: &[&str], +) -> std::option::Option { + let value = match value { + Some(value) => value, + None => return None, + }; + kb_extract_string_by_candidate_keys_inner(value, candidate_keys) +} + +fn kb_extract_string_by_candidate_keys_inner( + value: &serde_json::Value, + candidate_keys: &[&str], +) -> std::option::Option { + 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 = + kb_extract_string_by_candidate_keys_inner(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 = + kb_extract_string_by_candidate_keys_inner(nested_value, candidate_keys); + if nested_result.is_some() { + return nested_result; + } + } + } + None +} + +fn kb_value_contains_any_key( + value: std::option::Option<&serde_json::Value>, + candidate_keys: &[&str], +) -> bool { + let value = match value { + Some(value) => value, + None => return false, + }; + kb_value_contains_any_key_inner(value, candidate_keys) +} + +fn kb_value_contains_any_key_inner( + value: &serde_json::Value, + candidate_keys: &[&str], +) -> bool { + if let Some(object) = value.as_object() { + for candidate_key in candidate_keys { + if object.contains_key(*candidate_key) { + return true; + } + } + for nested_value in object.values() { + if kb_value_contains_any_key_inner(nested_value, candidate_keys) { + return true; + } + } + return false; + } + if let Some(array) = value.as_array() { + for nested_value in array { + if kb_value_contains_any_key_inner(nested_value, candidate_keys) { + return true; + } + } + } + false +} + +fn kb_extract_account( + accounts: &[std::string::String], + index: usize, +) -> std::option::Option { + if index >= accounts.len() { + return None; + } + Some(accounts[index].clone()) +} + +fn kb_infer_trade_side( + log_messages: &[std::string::String], +) -> crate::KbSwapTradeSide { + if kb_log_messages_contain_keyword(log_messages, "buy") { + return crate::KbSwapTradeSide::BuyBase; + } + if kb_log_messages_contain_keyword(log_messages, "sell") { + return crate::KbSwapTradeSide::SellBase; + } + crate::KbSwapTradeSide::Unknown +} + +#[cfg(test)] +mod tests { + fn make_create_transaction() -> crate::KbChainTransactionDto { + let mut dto = crate::KbChainTransactionDto::new( + "sig-orca-create-1".to_string(), + Some(891001), + Some(1779600001), + Some("helius_primary_http".to_string()), + Some("0".to_string()), + None, + None, + serde_json::json!({ + "slot": 891001, + "meta": { + "logMessages": [ + "Program log: Instruction: InitializePoolV2" + ] + }, + "transaction": { + "message": { + "instructions": [] + } + } + }) + .to_string(), + ); + dto.id = Some(601); + dto + } + + fn make_create_instruction() -> crate::KbChainInstructionDto { + let mut dto = crate::KbChainInstructionDto::new( + 601, + None, + 0, + None, + Some(crate::KB_ORCA_WHIRLPOOLS_PROGRAM_ID.to_string()), + Some("orca-whirlpools".to_string()), + Some(1), + serde_json::json!([ + "OrcaPool111", + "OrcaTokenA111", + "So11111111111111111111111111111111111111112", + "OrcaConfig111", + "OrcaCreator111" + ]) + .to_string(), + None, + None, + Some( + serde_json::json!({ + "info": { + "instruction": "initialize_pool_v2", + "whirlpool": "OrcaPool111", + "tokenMintA": "OrcaTokenA111", + "tokenMintB": "So11111111111111111111111111111111111111112", + "whirlpoolsConfig": "OrcaConfig111", + "funder": "OrcaCreator111", + "tokenProgramA": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "tokenProgramB": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + }) + .to_string(), + ), + ); + dto.id = Some(602); + dto + } + + fn make_swap_transaction() -> crate::KbChainTransactionDto { + let mut dto = crate::KbChainTransactionDto::new( + "sig-orca-swap-1".to_string(), + Some(891002), + Some(1779600002), + Some("helius_primary_http".to_string()), + Some("0".to_string()), + None, + None, + serde_json::json!({ + "slot": 891002, + "meta": { + "logMessages": [ + "Program log: Instruction: SwapV2" + ] + }, + "transaction": { + "message": { + "instructions": [] + } + } + }) + .to_string(), + ); + dto.id = Some(603); + dto + } + + fn make_swap_instruction() -> crate::KbChainInstructionDto { + let mut dto = crate::KbChainInstructionDto::new( + 603, + None, + 0, + None, + Some(crate::KB_ORCA_WHIRLPOOLS_PROGRAM_ID.to_string()), + Some("orca-whirlpools".to_string()), + Some(1), + serde_json::json!([ + "OrcaSwapPool111", + "OrcaSwapTokenA111", + "So11111111111111111111111111111111111111112" + ]) + .to_string(), + None, + None, + Some( + serde_json::json!({ + "info": { + "instruction": "swap_v2", + "whirlpool": "OrcaSwapPool111", + "tokenMintA": "OrcaSwapTokenA111", + "tokenMintB": "So11111111111111111111111111111111111111112" + } + }) + .to_string(), + ), + ); + dto.id = Some(604); + dto + } + + #[test] + fn orca_whirlpools_create_pool_is_detected() { + let decoder = crate::KbOrcaWhirlpoolsDecoder::new(); + let transaction = make_create_transaction(); + let instructions = vec![make_create_instruction()]; + let decoded_result = decoder.decode_transaction(&transaction, &instructions); + let decoded = match decoded_result { + Ok(decoded) => decoded, + Err(error) => panic!("decode must succeed: {}", error), + }; + assert_eq!(decoded.len(), 1); + match &decoded[0] { + crate::KbOrcaWhirlpoolsDecodedEvent::CreatePool(event) => { + assert_eq!(event.transaction_id, 601); + assert_eq!(event.instruction_id, 602); + assert_eq!(event.pool_account, Some("OrcaPool111".to_string())); + assert_eq!(event.token_a_mint, Some("OrcaTokenA111".to_string())); + assert_eq!( + event.token_b_mint, + Some("So11111111111111111111111111111111111111112".to_string()) + ); + assert_eq!(event.config_account, Some("OrcaConfig111".to_string())); + assert!(event.used_v2); + } + crate::KbOrcaWhirlpoolsDecodedEvent::Swap(_) => { + panic!("unexpected swap event") + } + } + } + + #[test] + fn orca_whirlpools_swap_is_detected() { + let decoder = crate::KbOrcaWhirlpoolsDecoder::new(); + let transaction = make_swap_transaction(); + let instructions = vec![make_swap_instruction()]; + let decoded_result = decoder.decode_transaction(&transaction, &instructions); + let decoded = match decoded_result { + Ok(decoded) => decoded, + Err(error) => panic!("decode must succeed: {}", error), + }; + assert_eq!(decoded.len(), 1); + match &decoded[0] { + crate::KbOrcaWhirlpoolsDecodedEvent::Swap(event) => { + assert_eq!(event.transaction_id, 603); + assert_eq!(event.instruction_id, 604); + assert_eq!(event.pool_account, Some("OrcaSwapPool111".to_string())); + assert_eq!(event.token_a_mint, Some("OrcaSwapTokenA111".to_string())); + assert_eq!( + event.token_b_mint, + Some("So11111111111111111111111111111111111111112".to_string()) + ); + assert!(event.used_v2); + } + crate::KbOrcaWhirlpoolsDecodedEvent::CreatePool(_) => { + panic!("unexpected create event") + } + } + } +} diff --git a/kb_lib/src/dex_decode.rs b/kb_lib/src/dex_decode.rs index 881c147..faaf34b 100644 --- a/kb_lib/src/dex_decode.rs +++ b/kb_lib/src/dex_decode.rs @@ -10,6 +10,7 @@ pub struct KbDexDecodeService { raydium_amm_v4_decoder: crate::KbRaydiumAmmV4Decoder, pump_fun_decoder: crate::KbPumpFunDecoder, pump_swap_decoder: crate::KbPumpSwapDecoder, + orca_whirlpools_decoder: crate::KbOrcaWhirlpoolsDecoder, meteora_dbc_decoder: crate::KbMeteoraDbcDecoder, meteora_damm_v1_decoder: crate::KbMeteoraDammV1Decoder, meteora_damm_v2_decoder: crate::KbMeteoraDammV2Decoder, @@ -25,6 +26,7 @@ impl KbDexDecodeService { raydium_amm_v4_decoder: crate::KbRaydiumAmmV4Decoder::new(), pump_fun_decoder: crate::KbPumpFunDecoder::new(), pump_swap_decoder: crate::KbPumpSwapDecoder::new(), + orca_whirlpools_decoder: crate::KbOrcaWhirlpoolsDecoder::new(), meteora_dbc_decoder: crate::KbMeteoraDbcDecoder::new(), meteora_damm_v1_decoder: crate::KbMeteoraDammV1Decoder::new(), meteora_damm_v2_decoder: crate::KbMeteoraDammV2Decoder::new(), @@ -173,9 +175,224 @@ impl KbDexDecodeService { }; persisted.push(persisted_event); } + let orca_whirlpools_decoded_result = self + .orca_whirlpools_decoder + .decode_transaction(&transaction, &instructions); + let orca_whirlpools_decoded = match orca_whirlpools_decoded_result { + Ok(orca_whirlpools_decoded) => orca_whirlpools_decoded, + Err(error) => return Err(error), + }; + for decoded_event in &orca_whirlpools_decoded { + let persist_result = self + .persist_orca_whirlpools_event(&transaction, decoded_event) + .await; + let persisted_event = match persist_result { + Ok(persisted_event) => persisted_event, + Err(error) => return Err(error), + }; + persisted.push(persisted_event); + } Ok(persisted) } + async fn persist_orca_whirlpools_event( + &self, + transaction: &crate::KbChainTransactionDto, + decoded_event: &crate::KbOrcaWhirlpoolsDecodedEvent, + ) -> Result { + match decoded_event { + crate::KbOrcaWhirlpoolsDecodedEvent::CreatePool(event) => { + let payload_json_result = serde_json::to_string(&event.payload_json); + let payload_json = match payload_json_result { + Ok(payload_json) => payload_json, + Err(error) => { + return Err(crate::KbError::Json(format!( + "cannot serialize decoded orca whirlpools payload: {}", + error + ))); + } + }; + let existing_result = crate::get_dex_decoded_event_by_key( + self.database.as_ref(), + event.transaction_id, + Some(event.instruction_id), + "orca_whirlpools.create_pool", + ) + .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::KbDexDecodedEventDto::new( + event.transaction_id, + Some(event.instruction_id), + "orca_whirlpools".to_string(), + event.program_id.clone(), + "orca_whirlpools.create_pool".to_string(), + event.pool_account.clone(), + None, + event.token_a_mint.clone(), + event.token_b_mint.clone(), + event.config_account.clone(), + payload_json, + ); + let upsert_result = + crate::upsert_dex_decoded_event(self.database.as_ref(), &dto).await; + if let Err(error) = upsert_result { + return Err(error); + } + let fetched_result = crate::get_dex_decoded_event_by_key( + self.database.as_ref(), + event.transaction_id, + Some(event.instruction_id), + "orca_whirlpools.create_pool", + ) + .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::KbError::InvalidState( + "decoded event disappeared after upsert".to_string(), + )); + } + }; + if !already_present { + let payload_value = event.payload_json.clone(); + let observation_result = self + .persistence + .record_observation(&crate::KbDetectionObservationInput::new( + "dex.orca_whirlpools.create_pool".to_string(), + crate::KbObservationSourceKind::HttpRpc, + transaction.source_endpoint_name.clone(), + transaction.signature.clone(), + transaction.slot, + payload_value.clone(), + )) + .await; + let observation_id = match observation_result { + Ok(observation_id) => observation_id, + Err(error) => return Err(error), + }; + let signal_result = self + .persistence + .record_signal(&crate::KbDetectionSignalInput::new( + "signal.dex.orca_whirlpools.create_pool".to_string(), + crate::KbAnalysisSignalSeverity::Low, + transaction.signature.clone(), + Some(observation_id), + None, + payload_value, + )) + .await; + if let Err(error) = signal_result { + return Err(error); + } + } + Ok(fetched) + } + crate::KbOrcaWhirlpoolsDecodedEvent::Swap(event) => { + let payload_json_result = serde_json::to_string(&event.payload_json); + let payload_json = match payload_json_result { + Ok(payload_json) => payload_json, + Err(error) => { + return Err(crate::KbError::Json(format!( + "cannot serialize decoded orca whirlpools payload: {}", + error + ))); + } + }; + let existing_result = crate::get_dex_decoded_event_by_key( + self.database.as_ref(), + event.transaction_id, + Some(event.instruction_id), + "orca_whirlpools.swap", + ) + .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::KbDexDecodedEventDto::new( + event.transaction_id, + Some(event.instruction_id), + "orca_whirlpools".to_string(), + event.program_id.clone(), + "orca_whirlpools.swap".to_string(), + event.pool_account.clone(), + None, + event.token_a_mint.clone(), + event.token_b_mint.clone(), + None, + payload_json, + ); + let upsert_result = + crate::upsert_dex_decoded_event(self.database.as_ref(), &dto).await; + if let Err(error) = upsert_result { + return Err(error); + } + let fetched_result = crate::get_dex_decoded_event_by_key( + self.database.as_ref(), + event.transaction_id, + Some(event.instruction_id), + "orca_whirlpools.swap", + ) + .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::KbError::InvalidState( + "decoded event disappeared after upsert".to_string(), + )); + } + }; + if !already_present { + let payload_value = event.payload_json.clone(); + let observation_result = self + .persistence + .record_observation(&crate::KbDetectionObservationInput::new( + "dex.orca_whirlpools.swap".to_string(), + crate::KbObservationSourceKind::HttpRpc, + transaction.source_endpoint_name.clone(), + transaction.signature.clone(), + transaction.slot, + payload_value.clone(), + )) + .await; + let observation_id = match observation_result { + Ok(observation_id) => observation_id, + Err(error) => return Err(error), + }; + let signal_result = self + .persistence + .record_signal(&crate::KbDetectionSignalInput::new( + "signal.dex.orca_whirlpools.swap".to_string(), + crate::KbAnalysisSignalSeverity::Low, + transaction.signature.clone(), + Some(observation_id), + None, + payload_value, + )) + .await; + if let Err(error) = signal_result { + return Err(error); + } + } + + Ok(fetched) + } + } + } + async fn persist_meteora_damm_v1_event( &self, transaction: &crate::KbChainTransactionDto, @@ -1629,4 +1846,96 @@ mod tests { Some("So11111111111111111111111111111111111111112".to_string()) ); } + + async fn seed_projected_orca_whirlpools_transaction( + database: std::sync::Arc, + signature: &str, + ) { + let service = crate::KbTransactionModelService::new(database); + let resolved_transaction = serde_json::json!({ + "slot": 999007, + "blockTime": 1779000007, + "version": 0, + "transaction": { + "message": { + "instructions": [ + { + "programId": crate::KB_ORCA_WHIRLPOOLS_PROGRAM_ID, + "program": "orca-whirlpools", + "stackHeight": 1, + "accounts": [ + "OrcaDecodePool111", + "OrcaDecodeTokenA111", + "So11111111111111111111111111111111111111112", + "OrcaDecodeConfig111", + "OrcaDecodeCreator111" + ], + "parsed": { + "info": { + "instruction": "initialize_pool_v2", + "whirlpool": "OrcaDecodePool111", + "tokenMintA": "OrcaDecodeTokenA111", + "tokenMintB": "So11111111111111111111111111111111111111112", + "whirlpoolsConfig": "OrcaDecodeConfig111", + "funder": "OrcaDecodeCreator111", + "tokenProgramA": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "tokenProgramB": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + }, + "data": "opaque" + } + ] + } + }, + "meta": { + "err": null, + "logMessages": [ + "Program log: Instruction: InitializePoolV2" + ] + } + }); + let persist_result = service + .persist_resolved_transaction( + signature, + Some("helius_primary_http".to_string()), + &resolved_transaction, + ) + .await; + if let Err(error) = persist_result { + panic!("projection must succeed: {}", error); + } + } + + #[tokio::test] + async fn decode_transaction_by_signature_persists_decoded_orca_whirlpools_event() { + let database = make_database().await; + seed_projected_orca_whirlpools_transaction(database.clone(), "sig-dex-decode-orca-1").await; + let service = crate::KbDexDecodeService::new(database.clone()); + let decoded_result = service + .decode_transaction_by_signature("sig-dex-decode-orca-1") + .await; + let decoded = match decoded_result { + Ok(decoded) => decoded, + Err(error) => panic!("decode must succeed: {}", error), + }; + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].protocol_name, "orca_whirlpools"); + assert_eq!(decoded[0].event_kind, "orca_whirlpools.create_pool"); + assert_eq!( + decoded[0].pool_account, + Some("OrcaDecodePool111".to_string()) + ); + assert_eq!( + decoded[0].token_a_mint, + Some("OrcaDecodeTokenA111".to_string()) + ); + assert_eq!( + decoded[0].token_b_mint, + Some("So11111111111111111111111111111111111111112".to_string()) + ); + assert_eq!( + decoded[0].market_account, + Some("OrcaDecodeConfig111".to_string()) + ); + } } diff --git a/kb_lib/src/dex_detect.rs b/kb_lib/src/dex_detect.rs index cdc6bb8..a324b83 100644 --- a/kb_lib/src/dex_detect.rs +++ b/kb_lib/src/dex_detect.rs @@ -201,6 +201,30 @@ impl KbDexDetectService { }; detection_results.push(detect_result); } + if decoded_event.protocol_name == "orca_whirlpools" + && decoded_event.event_kind == "orca_whirlpools.create_pool" + { + let detect_result = self + .detect_orca_whirlpools_pool(&transaction, decoded_event) + .await; + let detect_result = match detect_result { + Ok(detect_result) => detect_result, + Err(error) => return Err(error), + }; + detection_results.push(detect_result); + } + if decoded_event.protocol_name == "orca_whirlpools" + && decoded_event.event_kind == "orca_whirlpools.swap" + { + let detect_result = self + .detect_orca_whirlpools_pool(&transaction, decoded_event) + .await; + let detect_result = match detect_result { + Ok(detect_result) => detect_result, + Err(error) => return Err(error), + }; + detection_results.push(detect_result); + } } Ok(detection_results) } @@ -1686,6 +1710,276 @@ impl KbDexDetectService { }) } + async fn detect_orca_whirlpools_pool( + &self, + transaction: &crate::KbChainTransactionDto, + decoded_event: &crate::KbDexDecodedEventDto, + ) -> Result { + let decoded_event_id_option = decoded_event.id; + let decoded_event_id = match decoded_event_id_option { + Some(decoded_event_id) => decoded_event_id, + None => { + return Err(crate::KbError::InvalidState( + "decoded dex event has no internal id".to_string(), + )); + } + }; + let dex_id_result = self.ensure_orca_whirlpools_dex().await; + let dex_id = match dex_id_result { + Ok(dex_id) => dex_id, + Err(error) => return Err(error), + }; + let pool_address_option = decoded_event.pool_account.clone(); + let pool_address = match pool_address_option { + Some(pool_address) => pool_address, + None => { + return Err(crate::KbError::InvalidState(format!( + "decoded event '{}' has no pool_account", + decoded_event_id + ))); + } + }; + let token_a_mint_option = decoded_event.token_a_mint.clone(); + let token_a_mint = match token_a_mint_option { + Some(token_a_mint) => token_a_mint, + None => { + return Err(crate::KbError::InvalidState(format!( + "decoded event '{}' has no token_a_mint", + decoded_event_id + ))); + } + }; + let token_b_mint_option = decoded_event.token_b_mint.clone(); + let token_b_mint = match token_b_mint_option { + Some(token_b_mint) => token_b_mint, + None => { + return Err(crate::KbError::InvalidState(format!( + "decoded event '{}' has no token_b_mint", + decoded_event_id + ))); + } + }; + let base_is_token_a = + kb_choose_base_quote_order(token_a_mint.as_str(), token_b_mint.as_str()); + let base_mint = if base_is_token_a { + token_a_mint.clone() + } else { + token_b_mint.clone() + }; + let quote_mint = if base_is_token_a { + token_b_mint.clone() + } else { + token_a_mint.clone() + }; + let base_token_id_result = self.ensure_token(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 = self.ensure_token(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 existing_pool_result = + crate::get_pool_by_address(self.database.as_ref(), 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) => { + let pool_id_option = pool.id; + match pool_id_option { + Some(pool_id) => pool_id, + None => { + return Err(crate::KbError::InvalidState(format!( + "pool '{}' has no internal id", + pool.address + ))); + } + } + } + None => { + let pool_dto = crate::KbPoolDto::new( + dex_id, + pool_address.clone(), + crate::KbPoolKind::Clmm, + crate::KbPoolStatus::Active, + ); + let upsert_result = crate::upsert_pool(self.database.as_ref(), &pool_dto).await; + match upsert_result { + Ok(pool_id) => pool_id, + Err(error) => return Err(error), + } + } + }; + let existing_pair_result = + crate::get_pair_by_pool_id(self.database.as_ref(), 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 = kb_build_pair_symbol(base_mint.as_str(), quote_mint.as_str()); + let pair_id = match existing_pair_option { + Some(pair) => { + let pair_id_option = pair.id; + match pair_id_option { + Some(pair_id) => pair_id, + None => { + return Err(crate::KbError::InvalidState(format!( + "pair for pool '{}' has no internal id", + pool_id + ))); + } + } + } + None => { + let pair_dto = crate::KbPairDto::new( + dex_id, + pool_id, + base_token_id, + quote_token_id, + pair_symbol, + ); + let upsert_result = crate::upsert_pair(self.database.as_ref(), &pair_dto).await; + match upsert_result { + Ok(pair_id) => pair_id, + Err(error) => return Err(error), + } + } + }; + let upsert_base_pool_token_result = crate::upsert_pool_token( + self.database.as_ref(), + &crate::KbPoolTokenDto::new( + pool_id, + base_token_id, + crate::KbPoolTokenRole::Base, + None, + Some(0), + ), + ) + .await; + if let Err(error) = upsert_base_pool_token_result { + return Err(error); + } + let upsert_quote_pool_token_result = crate::upsert_pool_token( + self.database.as_ref(), + &crate::KbPoolTokenDto::new( + pool_id, + quote_token_id, + crate::KbPoolTokenRole::Quote, + None, + Some(1), + ), + ) + .await; + if let Err(error) = upsert_quote_pool_token_result { + return Err(error); + } + let existing_listing_result = + crate::get_pool_listing_by_pool_id(self.database.as_ref(), 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_id_result = self + .upsert_pool_listing_from_decoded_event(dex_id, pool_id, pair_id, transaction) + .await; + match listing_id_result { + Ok(listing_id) => Some(listing_id), + Err(error) => return Err(error), + } + } + }; + let payload_value_result = kb_parse_payload_json(decoded_event.payload_json.as_str()); + let payload_value = match payload_value_result { + Ok(payload_value) => payload_value, + Err(error) => return Err(error), + }; + if created_pool { + let signal_result = self + .record_detection_signal( + transaction, + "signal.dex.orca_whirlpools.new_pool", + crate::KbAnalysisSignalSeverity::Low, + payload_value.clone(), + ) + .await; + if let Err(error) = signal_result { + return Err(error); + } + } + if created_pair { + let signal_result = self + .record_detection_signal( + transaction, + "signal.dex.orca_whirlpools.new_pair", + crate::KbAnalysisSignalSeverity::Low, + payload_value.clone(), + ) + .await; + if let Err(error) = signal_result { + return Err(error); + } + } + if created_listing { + let signal_result = self + .record_detection_signal( + transaction, + "signal.dex.orca_whirlpools.first_listing_seen", + crate::KbAnalysisSignalSeverity::Low, + payload_value, + ) + .await; + if let Err(error) = signal_result { + return Err(error); + } + } + Ok(crate::KbDexPoolDetectionResult { + decoded_event_id, + dex_id, + pool_id, + pair_id, + pool_listing_id, + created_pool, + created_pair, + created_listing, + }) + } + + async fn ensure_orca_whirlpools_dex(&self) -> Result { + let dex_result = crate::get_dex_by_code(self.database.as_ref(), "orca_whirlpools").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) => Ok(dex_id), + None => Err(crate::KbError::InvalidState( + "orca_whirlpools dex has no internal id".to_string(), + )), + }, + None => { + let dex_dto = crate::KbDexDto::new( + "orca_whirlpools".to_string(), + "Orca Whirlpools".to_string(), + Some(crate::KB_ORCA_WHIRLPOOLS_PROGRAM_ID.to_string()), + None, + true, + ); + crate::upsert_dex(self.database.as_ref(), &dex_dto).await + } + } + } + async fn ensure_meteora_damm_v1_dex(&self) -> Result { let dex_result = crate::get_dex_by_code(self.database.as_ref(), "meteora_damm_v1").await; let dex_option = match dex_result { @@ -2785,4 +3079,126 @@ mod tests { }; assert_eq!(pool_tokens.len(), 2); } + + async fn seed_decoded_orca_whirlpools_event( + database: std::sync::Arc, + signature: &str, + ) { + let transaction_model = crate::KbTransactionModelService::new(database.clone()); + let dex_decode = crate::KbDexDecodeService::new(database); + let resolved_transaction = serde_json::json!({ + "slot": 910007, + "blockTime": 1779100007, + "version": 0, + "transaction": { + "message": { + "instructions": [ + { + "programId": crate::KB_ORCA_WHIRLPOOLS_PROGRAM_ID, + "program": "orca-whirlpools", + "stackHeight": 1, + "accounts": [ + "OrcaDetectPool111", + "OrcaDetectTokenA111", + "So11111111111111111111111111111111111111112", + "OrcaDetectConfig111", + "OrcaDetectCreator111" + ], + "parsed": { + "info": { + "instruction": "initialize_pool_v2", + "whirlpool": "OrcaDetectPool111", + "tokenMintA": "OrcaDetectTokenA111", + "tokenMintB": "So11111111111111111111111111111111111111112", + "whirlpoolsConfig": "OrcaDetectConfig111", + "funder": "OrcaDetectCreator111", + "tokenProgramA": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "tokenProgramB": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + }, + "data": "opaque" + } + ] + } + }, + "meta": { + "err": null, + "logMessages": [ + "Program log: Instruction: InitializePoolV2" + ] + } + }); + let project_result = transaction_model + .persist_resolved_transaction( + signature, + Some("helius_primary_http".to_string()), + &resolved_transaction, + ) + .await; + if let Err(error) = project_result { + panic!("projection must succeed: {}", error); + } + let decode_result = dex_decode.decode_transaction_by_signature(signature).await; + if let Err(error) = decode_result { + panic!("dex decode must succeed: {}", error); + } + } + + #[tokio::test] + async fn detect_transaction_by_signature_creates_orca_whirlpools_pool_pair_and_listing() { + let database = make_database().await; + seed_decoded_orca_whirlpools_event(database.clone(), "sig-dex-detect-orca-1").await; + let detect_service = crate::KbDexDetectService::new(database.clone()); + let detect_result = detect_service + .detect_transaction_by_signature("sig-dex-detect-orca-1") + .await; + let results = match detect_result { + Ok(results) => results, + Err(error) => panic!("dex detect must succeed: {}", error), + }; + assert_eq!(results.len(), 1); + assert!(results[0].created_pool); + assert!(results[0].created_pair); + assert!(results[0].created_listing); + let pool_result = + crate::get_pool_by_address(database.as_ref(), "OrcaDetectPool111").await; + let pool_option = match pool_result { + Ok(pool_option) => pool_option, + Err(error) => panic!("pool fetch must succeed: {}", error), + }; + let pool = match pool_option { + Some(pool) => pool, + None => panic!("pool must exist"), + }; + assert_eq!(pool.id, Some(results[0].pool_id)); + assert_eq!(pool.pool_kind, crate::KbPoolKind::Clmm); + let pair_result = crate::get_pair_by_pool_id(database.as_ref(), results[0].pool_id).await; + let pair_option = match pair_result { + Ok(pair_option) => pair_option, + Err(error) => panic!("pair fetch must succeed: {}", error), + }; + let pair = match pair_option { + Some(pair) => pair, + None => panic!("pair must exist"), + }; + assert_eq!(pair.id, Some(results[0].pair_id)); + let listing_result = + crate::get_pool_listing_by_pool_id(database.as_ref(), results[0].pool_id).await; + let listing_option = match listing_result { + Ok(listing_option) => listing_option, + Err(error) => panic!("listing fetch must succeed: {}", error), + }; + let listing = match listing_option { + Some(listing) => listing, + None => panic!("listing must exist"), + }; + assert_eq!(listing.id, results[0].pool_listing_id); + let pool_tokens_result = + crate::list_pool_tokens_by_pool_id(database.as_ref(), results[0].pool_id).await; + let pool_tokens = match pool_tokens_result { + Ok(pool_tokens) => pool_tokens, + Err(error) => panic!("pool tokens list must succeed: {}", error), + }; + assert_eq!(pool_tokens.len(), 2); + } } diff --git a/kb_lib/src/lib.rs b/kb_lib/src/lib.rs index 3ce55cd..0ed916b 100644 --- a/kb_lib/src/lib.rs +++ b/kb_lib/src/lib.rs @@ -246,5 +246,10 @@ pub use dex::KbMeteoraDammV2SwapDecoded; pub use dex_decode::KbDexDecodeService; pub use dex_detect::KbDexDetectService; pub use dex_detect::KbDexPoolDetectionResult; +pub use dex::KB_ORCA_WHIRLPOOLS_PROGRAM_ID; +pub use dex::KbOrcaWhirlpoolsCreatePoolDecoded; +pub use dex::KbOrcaWhirlpoolsDecodedEvent; +pub use dex::KbOrcaWhirlpoolsDecoder; +pub use dex::KbOrcaWhirlpoolsSwapDecoded; pub use launch_origin::KbLaunchAttributionResult; pub use launch_origin::KbLaunchOriginService;