diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d05125..82b271a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,3 +26,4 @@ 0.6.0 - Ajout du pipeline de détection technique : façade de persistance pour observations on-chain, signaux d’analyse et candidats tokens depuis les connecteurs RPC 0.6.1 - Ajout du bridge de détection Solana WS : notifications JSON-RPC persistées en observations, avec détection initiale des mints SPL / Token-2022 depuis programNotification 0.6.2 - Branchement de WsClient vers le pipeline de détection via un relais asynchrone de notifications JSON-RPC WebSocket +0.6.3 - Enrichissement des notifications WebSocket utiles : extraction améliorée de pubkey, signature, owner, parsed account type et slot pour account/logs/signature notifications diff --git a/Cargo.toml b/Cargo.toml index 6ce7614..3906389 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.6.2" +version = "0.6.3" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-bobobot" diff --git a/ROADMAP.md b/ROADMAP.md index 68a7ece..4ad38e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,7 +140,7 @@ Le tracing est centralisé dans `kb_lib`. ## 6. Phasage par versions -### 6.1. Version `0.0.2` — Socle conforme +### 6.001. Version `0.0.2` — Socle conforme Objectif : corriger le squelette et poser la base de travail. Réalisé : @@ -155,7 +155,7 @@ Réalisé : - documentation de `kb_app/src/splash.rs`, - UI Tauri minimale. -### 6.2. Version `0.1.x` — Transport WebSocket générique +### 6.002. Version `0.1.x` — Transport WebSocket générique Objectif : construire un vrai `WsClient` asynchrone clonable. Réalisé : @@ -169,7 +169,7 @@ Réalisé : - fermeture avec timeout, - tests offline avec serveur mock. -### 6.3. Version `0.1.1` — Intégration Tauri minimale du `WsClient` +### 6.003. Version `0.1.1` — Intégration Tauri minimale du `WsClient` Objectif : valider le transport via l’application desktop. Réalisé : @@ -179,7 +179,7 @@ Réalisé : - zone de logs, - validation du flux `frontend -> tauri -> kb_lib -> frontend`. -### 6.4. Version `0.2.0` — Couche JSON-RPC WS Solana +### 6.004. Version `0.2.0` — Couche JSON-RPC WS Solana Objectif : séparer clairement transport, réponses RPC et notifications. Réalisé : @@ -190,7 +190,7 @@ Réalisé : - parsing des notifications, - premiers helpers JSON-RPC sur `WsClient`. -### 6.5. Version `0.3.0` — Registre subscriptions / notifications +### 6.005. Version `0.3.0` — Registre subscriptions / notifications Objectif : fiabiliser la gestion des subscriptions. Réalisé : @@ -202,7 +202,7 @@ Réalisé : - purge locale si nécessaire, - routage séparé des notifications. -### 6.6. Version `0.3.1` — Helpers subscribe/unsubscribe WebSocket +### 6.006. Version `0.3.1` — Helpers subscribe/unsubscribe WebSocket Objectif : ajouter les helpers haut niveau correspondant aux principales méthodes PubSub Solana. Réalisé : @@ -211,7 +211,7 @@ Réalisé : - helpers d’unsubscribe correspondants, - premiers tests de validation des noms de méthodes. -### 6.7. Version `0.3.2` — Helpers typed et notifications typed +### 6.007. Version `0.3.2` — Helpers typed et notifications typed Objectif : s’appuyer principalement sur `solana-rpc-client-api` pour typer les subscribe et les notifications. Réalisé : @@ -220,7 +220,7 @@ Réalisé : - parsing typed des notifications, - base de travail pour réduire l’usage direct de `serde_json::Value`. -### 6.8. Version `0.3.3` — Distinction API typed / raw +### 6.008. Version `0.3.3` — Distinction API typed / raw Objectif : clarifier l’API publique de `WsClient`. Réalisé : @@ -229,7 +229,7 @@ Réalisé : - conservation des helpers typed comme interface plus propre, - préparation d’une hiérarchie API plus explicite. -### 6.9. Version `0.3.4` — Fenêtre `Demo Ws` dans `kb_app` +### 6.009. Version `0.3.4` — Fenêtre `Demo Ws` dans `kb_app` Objectif : tester manuellement les souscriptions live dans une fenêtre dédiée. Réalisé : @@ -241,7 +241,7 @@ Réalisé : - affichage des événements raw et typed, - premiers tests réels sur `wss://api.mainnet.solana.com`. -### 6.10. Version `0.3.5` — Stabilisation de `Demo Ws` +### 6.010. Version `0.3.5` — Stabilisation de `Demo Ws` Objectif : rendre la fenêtre de démonstration robuste sous flux élevé et cohérente avec la configuration. Réalisé : @@ -255,11 +255,11 @@ Réalisé : - conserver des compteurs et états UI exploitables, - mieux gérer les fermetures/ralentissements d’endpoints publics. -### 6.11. Version `0.4.x` — Transport HTTP générique et helpers RPC +### 6.011. Version `0.4.x` — Transport HTTP générique et helpers RPC Objectif : construire un `HttpClient` clonable, limité et extensible, puis ajouter les premiers helpers HTTP Solana. -### 6.12. Version `0.4.0` — Socle `HttpClient` +### 6.012. Version `0.4.0` — Socle `HttpClient` Réalisé : - client `reqwest` asynchrone clonable, @@ -280,7 +280,7 @@ Livrables : - `getVersion` - `getSlot` -### 6.13. Version `0.4.1` — Helpers HTTP Solana +### 6.013. Version `0.4.1` — Helpers HTTP Solana Réalisé : - ajouter des helpers HTTP haut niveau comme pour le client WS, @@ -288,7 +288,7 @@ Réalisé : - couvrir les premières méthodes utiles du RPC HTTP Solana, - conserver `HttpClient` comme couche générique réutilisable. -### 6.14. Version `0.4.2` — Politique HTTP avancée +### 6.014. Version `0.4.2` — Politique HTTP avancée Réalisé : - préparer un état de pause avant envoi pour un endpoint HTTP, @@ -296,7 +296,7 @@ Réalisé : - distinguer quota RPC général et quota `sendTransaction`, - préparer un futur pool d’endpoints HTTP et l’arbitrage entre eux. -### 6.15. Version `0.4.3` — Pool d’endpoints HTTP +### 6.015. Version `0.4.3` — Pool d’endpoints HTTP Réalisé : - ajouter un pool d’`HttpClient`, @@ -306,7 +306,7 @@ Réalisé : - prendre en compte la classe de méthode HTTP, - préparer le routage multi-RPC et la limitation de concurrence par endpoint. -### 6.16. Version `0.4.4` — Démo HTTP dans `kb_app` +### 6.016. Version `0.4.4` — Démo HTTP dans `kb_app` Réalisé : - ajout d’une fenêtre `Demo Http`, @@ -317,10 +317,10 @@ Réalisé : - alignement visuel de la fenêtre sur le gabarit `Demo Ws`, - amélioration des presets UI, copie de réponse et bascule pretty/raw. -### 6.17. Version `0.5.x` — Base de données SQLite +### 6.017. Version `0.5.x` — Base de données SQLite Objectif : poser la persistance locale avec une organisation préparée dès le départ à une future évolution vers PostgreSQL ou un autre backend. -### 6.18. Version `0.5.0` — Socle SQLite +### 6.018. Version `0.5.0` — Socle SQLite Réalisé : - configuration DB dans `config.json`, @@ -330,7 +330,7 @@ Réalisé : - table `kb_db_metadata`, - séparation `db/entities`, `db/dtos`, `db/queries`, `db/types`. -### 6.19. Version `0.5.1` — Premières tables métier de stockage local +### 6.019. Version `0.5.1` — Premières tables métier de stockage local Réalisé : - ajout des tables de référence pour les endpoints connus HTTP/WS, @@ -338,7 +338,7 @@ Réalisé : - mise en place des `entities`, `dtos`, `queries` et `types` associés, - préparation du stockage local des endpoints HTTP/WS connus et de leur état utile. -### 6.20. Version `0.5.2` — Stockage des tokens observés +### 6.020. Version `0.5.2` — Stockage des tokens observés Réalisé : - ajout de la table `kb_observed_tokens`, @@ -347,7 +347,7 @@ Réalisé : - préparation des relations futures avec pools, paires et événements on-chain, - conservation d’unicité locale par mint sans duplication par endpoint. -### 6.21. Version `0.5.3` — Événements et signaux locaux +### 6.021. Version `0.5.3` — Événements et signaux locaux Réalisé : - conservation des événements runtime techniques via `kb_db_runtime_events`, @@ -356,7 +356,7 @@ Réalisé : - distinction explicite entre événements runtime, observations on-chain et événements métier, - préparation de la traçabilité de provenance par type de source et endpoint, sans remettre en cause l’unicité locale d’un token par mint. -### 6.22. Version `0.5.4` — Modèle métier normalisé initial +### 6.022. Version `0.5.4` — Modèle métier normalisé initial Réalisé : - ajouter les tables de référence métier pour les DEX, tokens, pools et paires, @@ -364,7 +364,7 @@ Réalisé : - préparer les relations entre tokens, pools, paires et listings, - éviter que la détection technique `0.6.x` écrive directement dans des tables trop brutes ou ambiguës. -### 6.23. Version `0.5.5` — Activité métier normalisée +### 6.023. Version `0.5.5` — Activité métier normalisée Réalisé : - ajout des tables de swaps, @@ -372,7 +372,7 @@ Réalisé : - ajout des événements de mint et burn utiles au suivi des tokens, - préparation de l’historique métier nécessaire avant l’arrivée des connecteurs DEX complets. -### 6.24. Version `0.5.6` — Consolidation de la couche stockage +### 6.024. Version `0.5.6` — Consolidation de la couche stockage Objectif : stabiliser le schéma avant la détection technique réelle. À faire : @@ -383,7 +383,7 @@ Objectif : stabiliser le schéma avant la détection technique réelle. - durcir les relations, contraintes et index utiles, - préparer une future compatibilité PostgreSQL sans casser l’organisation générale. -### 6.25. Version `0.6.0` — Pipeline de détection technique +### 6.025. Version `0.6.0` — Pipeline de détection technique Objectif : relier les connecteurs RPC à la couche de stockage technique et métier. À faire : @@ -393,7 +393,7 @@ Objectif : relier les connecteurs RPC à la couche de stockage technique et mét - éviter que les futurs watchers RPC écrivent directement dans la DB sans couche intermédiaire, - préparer les prochaines étapes de détection technique on-chain / RPC. -### 6.26. Version `0.6.1` — Détection technique RPC +### 6.026. Version `0.6.1` — Détection technique RPC Réalisé : - ajout d’un bridge `Solana WS notification -> pipeline de détection`, @@ -401,27 +401,23 @@ Réalisé : - génération d’un candidat token quand une `programNotification` expose un mint SPL / Token-2022 en JSON parsé, - préparation du branchement futur des watchers et règles RPC réelles sur une façade de détection unique. -### 6.27. Version `0.6.2` — Branchement `WsClient` vers la détection -Objectif : relier directement les notifications WS issues de `WsClient` au pipeline de détection. - -À faire : +### 6.027. Version `0.6.2` — Branchement `WsClient` vers la détection +Réalisé : - ajouter un relais interne de notifications WS vers la couche de détection, - permettre à `WsClient` de forwarder les `JsonRpcWsNotification` vers un worker dédié, - conserver le découplage entre transport WS et logique de détection, - éviter de bloquer la boucle de lecture WS si la détection est lente. -### 6.28. Version `0.6.3` — Enrichissement des notifications WS utiles -Objectif : améliorer la couverture technique des notifications WS. +### 6.028. Version `0.6.3` — Enrichissement des notifications WS utiles +Réalisé : -À faire : - -- enrichir `accountNotification`, `logsNotification`, `signatureNotification`, -- mieux extraire slot, pubkey, signature, owner, parsed account type, +- enrichir `accountNotification`, `logsNotification` et `signatureNotification`, +- mieux extraire slot, pubkey, signature, owner, parsed account type et clés pertinentes, - produire des observations plus précises et plus homogènes, - préparer les règles de détection techniques réelles. -### 6.29. Version `0.6.4` — Premières règles de détection technique +### 6.029. Version `0.6.4` — Premières règles de détection technique Objectif : commencer la détection technique on-chain utile avant les connecteurs DEX dédiés. À faire : @@ -431,7 +427,17 @@ Objectif : commencer la détection technique on-chain utile avant les connecteur - préparer l’alimentation des tables métier normalisées, - garder la logique encore indépendante des connecteurs DEX `0.7.x`. -### 6.30. Version `0.7.x` — DEX connectors v1 +### 6.030. Version `0.6.5` — Orchestration multi-clients WebSocket +Objectif : préparer la gestion coordonnée de plusieurs `WsClient`. + +À faire : + +- introduire une abstraction `ws_pool.rs` ou `ws_manager.rs`, +- piloter plusieurs clients WS selon les rôles d’endpoints configurés, +- centraliser le démarrage, l’arrêt, l’état et les relais de notifications WS, +- préparer les futures politiques de répartition, supervision et reconnexion. + +### 6.031. Version `0.7.x` — DEX connectors v1 Objectif : structurer les connecteurs par protocole. Cibles initiales possibles : @@ -450,7 +456,7 @@ Cibles initiales possibles : - création de types métiers propres, - enrichissement des métadonnées token/pool/pair. -### 6.31. Version `0.8.x` — Analyse et filtrage +### 6.032. Version `0.8.x` — Analyse et filtrage Objectif : transformer les événements bruts en signaux exploitables. À faire : @@ -461,7 +467,7 @@ Objectif : transformer les événements bruts en signaux exploitables. - statistiques de comportement, - premiers patterns. -### 6.32. Version `1.x.y` — Wallets et swap préparatoire +### 6.033. Version `1.x.y` — Wallets et swap préparatoire Objectif : préparer la couche d’action. À faire : @@ -472,7 +478,7 @@ Objectif : préparer la couche d’action. - préparation d’ordres et de swaps, - simulation et garde-fous. -### 6.33. Version `2.x.y` — Trading semi-automatisé +### 6.034. Version `2.x.y` — Trading semi-automatisé Objectif : brancher l’analyse à l’action tout en gardant des garde-fous explicites. À faire : @@ -483,7 +489,7 @@ Objectif : brancher l’analyse à l’action tout en gardant des garde-fous exp - confirmations explicites ou semi-automatiques, - journaux d’exécution. -### 6.34. Version `3.x.y` — Yellowstone gRPC +### 6.035. Version `3.x.y` — Yellowstone gRPC Objectif : ajouter le connecteur gRPC dédié. À faire : @@ -568,9 +574,9 @@ Le projet doit maintenir au minimum : ## 12. Priorité immédiate La priorité immédiate est désormais la suivante : -1. démarrer la version `0.6.2` avec le branchement `WsClient` vers la détection, -2. ajouter un relais asynchrone entre notifications WS et worker de détection, -3. éviter de bloquer la boucle de lecture du client WS, -4. préparer ensuite la version `0.6.3` pour enrichir les notifications WS utiles, +1. démarrer la version `0.6.3` avec l’enrichissement des notifications WS utiles, +2. améliorer l’extraction des métadonnées utiles depuis `accountNotification`, `logsNotification` et `signatureNotification`, +3. produire des observations on-chain plus précises et homogènes, +4. préparer ensuite la version `0.6.4` pour les premières règles de détection technique, 5. conserver le découplage entre transport, détection et stockage, -6. préparer enfin `0.6.4` pour les premières règles de détection technique. +6. planifier enfin `0.6.5` pour l’orchestration multi-clients WebSocket via `ws_pool.rs` ou `ws_manager.rs`. diff --git a/kb_lib/src/detect/solana_ws.rs b/kb_lib/src/detect/solana_ws.rs index 82d6bed..c806d13 100644 --- a/kb_lib/src/detect/solana_ws.rs +++ b/kb_lib/src/detect/solana_ws.rs @@ -73,10 +73,10 @@ impl KbSolanaWsDetectionService { let observation_input = crate::KbDetectionObservationInput::new( observation_kind, crate::KbObservationSourceKind::WsRpc, - endpoint_name, - object_key, + endpoint_name.clone(), + object_key.clone(), slot, - payload, + payload.clone(), ); let observation_id_result = self .persistence @@ -86,6 +86,32 @@ impl KbSolanaWsDetectionService { Ok(observation_id) => observation_id, Err(error) => return Err(error), }; + let should_emit_signal = match notification.method.as_str() { + "accountNotification" => true, + "logsNotification" => true, + "signatureNotification" => true, + _ => false, + }; + if should_emit_signal { + let signal_input = crate::KbDetectionSignalInput::new( + build_signal_kind_for_notification( + notification.method.as_str(), + ¬ification.params.result, + ), + build_signal_severity_for_notification( + notification.method.as_str(), + ¬ification.params.result, + ), + object_key, + Some(observation_id), + None, + payload, + ); + let signal_result = self.persistence.record_signal(&signal_input).await; + if let Err(error) = signal_result { + return Err(error); + } + } Ok(crate::KbSolanaWsDetectionOutcome::ObservationRecorded { observation_id }) } @@ -114,9 +140,6 @@ impl KbSolanaWsDetectionService { Some(parsed_type) => parsed_type, None => return Ok(None), }; - if parsed_type.as_str() != "mint" { - return Ok(None); - } let token_program_option = extract_account_owner(account_value); let token_program = match token_program_option { Some(token_program) => token_program, @@ -127,13 +150,30 @@ impl KbSolanaWsDetectionService { { return Ok(None); } - let decimals = extract_decimals_from_account_value(account_value); + let decimals = if parsed_type.as_str() == "mint" { + extract_decimals_from_account_value(account_value) + } else if parsed_type.as_str() == "account" { + extract_token_account_decimals_from_account_value(account_value) + } else { + None + }; + let mint = if parsed_type.as_str() == "mint" { + pubkey.clone() + } else if parsed_type.as_str() == "account" { + let mint_option = extract_parsed_account_mint(account_value); + match mint_option { + Some(mint) => mint, + None => return Ok(None), + } + } else { + return Ok(None); + }; let slot = extract_slot_from_result(notification.method.as_str(), ¬ification.params.result); let payload = build_notification_payload(notification); - let is_quote_token = pubkey == crate::WSOL_MINT_ID.to_string(); + let is_quote_token = mint == crate::WSOL_MINT_ID.to_string(); let input = crate::KbDetectionTokenCandidateInput::new( - pubkey, + mint, None, None, decimals, @@ -144,7 +184,11 @@ impl KbSolanaWsDetectionService { slot, "ws.program_notification".to_string(), payload.clone(), - "signal.token_mint_account_detected".to_string(), + if parsed_type.as_str() == "mint" { + "signal.token_mint_account_detected".to_string() + } else { + "signal.token_account_detected".to_string() + }, crate::KbAnalysisSignalSeverity::Medium, None, Some(payload), @@ -342,6 +386,235 @@ fn extract_decimals_from_account_value( } } +/// Extracts the owner program id from one program notification result. +fn extract_program_notification_owner( + result: &serde_json::Value, +) -> std::option::Option { + let account_value_option = extract_account_value_from_result(result); + let account_value = match account_value_option { + Some(account_value) => account_value, + None => return None, + }; + extract_account_owner(account_value) +} + +/// Extracts the parsed token amount decimals from one parsed token account notification. +fn extract_token_account_decimals_from_account_value( + account_value: &serde_json::Value, +) -> std::option::Option { + let data_option = account_value.get("data"); + let data = match data_option { + Some(data) => data, + None => return None, + }; + let parsed_option = data.get("parsed"); + let parsed = match parsed_option { + Some(parsed) => parsed, + None => return None, + }; + let info_option = parsed.get("info"); + let info = match info_option { + Some(info) => info, + None => return None, + }; + let token_amount_option = info.get("tokenAmount"); + let token_amount = match token_amount_option { + Some(token_amount) => token_amount, + None => return None, + }; + let decimals_option = token_amount + .get("decimals") + .and_then(serde_json::Value::as_u64); + let decimals = match decimals_option { + Some(decimals) => decimals, + None => return None, + }; + let convert_result = u8::try_from(decimals); + match convert_result { + Ok(decimals) => Some(decimals), + Err(_) => None, + } +} + +/// Extracts a parsed account mint from one account-like JSON object. +fn extract_parsed_account_mint( + account_value: &serde_json::Value, +) -> std::option::Option { + let data_option = account_value.get("data"); + let data = match data_option { + Some(data) => data, + None => return None, + }; + let parsed_option = data.get("parsed"); + let parsed = match parsed_option { + Some(parsed) => parsed, + None => return None, + }; + let info_option = parsed.get("info"); + let info = match info_option { + Some(info) => info, + None => return None, + }; + let mint_option = info.get("mint").and_then(serde_json::Value::as_str); + match mint_option { + Some(mint) => Some(mint.to_string()), + None => None, + } +} + +/// Extracts log lines from one logs notification result. +fn extract_logs_lines(result: &serde_json::Value) -> std::vec::Vec { + let mut lines = std::vec::Vec::new(); + let value_option = result.get("value"); + let value = match value_option { + Some(value) => value, + None => return lines, + }; + let logs_option = value.get("logs"); + let logs = match logs_option { + Some(logs) => logs, + None => return lines, + }; + let array_option = logs.as_array(); + let array = match array_option { + Some(array) => array, + None => return lines, + }; + for item in array { + let line_option = item.as_str(); + if let Some(line) = line_option { + lines.push(line.to_string()); + } + } + lines +} + +/// Extracts the error field from a signature notification result. +fn extract_signature_notification_err( + result: &serde_json::Value, +) -> std::option::Option { + let value_option = result.get("value"); + let value = match value_option { + Some(value) => value, + None => return None, + }; + match value.get("err") { + Some(err) => Some(err.clone()), + None => None, + } +} + +/// Builds a more specific signal kind for one WebSocket notification. +fn build_signal_kind_for_notification( + method: &str, + result: &serde_json::Value, +) -> std::string::String { + match method { + "accountNotification" => { + let account_value_option = extract_account_value_from_result(result); + if let Some(account_value) = account_value_option { + let parsed_type_option = extract_parsed_account_type(account_value); + if let Some(parsed_type) = parsed_type_option { + return format!("signal.account_notification.{parsed_type}"); + } + let owner_option = extract_account_owner(account_value); + if let Some(owner) = owner_option { + if owner == crate::SPL_TOKEN_PROGRAM_ID.to_string() { + return "signal.account_notification.spl_token".to_string(); + } + if owner == crate::SPL_TOKEN_2022_PROGRAM_ID.to_string() { + return "signal.account_notification.spl_token_2022".to_string(); + } + } + } + "signal.account_notification.generic".to_string() + } + "logsNotification" => { + let lines = extract_logs_lines(result); + for line in &lines { + if line.contains("Instruction: InitializeMint") { + return "signal.logs_notification.initialize_mint".to_string(); + } + if line.contains("Instruction: MintTo") { + return "signal.logs_notification.mint_to".to_string(); + } + if line.contains("Instruction: Burn") { + return "signal.logs_notification.burn".to_string(); + } + if line.contains("Instruction: InitializeAccount") { + return "signal.logs_notification.initialize_account".to_string(); + } + } + "signal.logs_notification.generic".to_string() + } + "signatureNotification" => { + let err_option = extract_signature_notification_err(result); + match err_option { + Some(err) => { + if err.is_null() { + "signal.signature_notification.confirmed".to_string() + } else { + "signal.signature_notification.failed".to_string() + } + } + None => "signal.signature_notification.generic".to_string(), + } + } + "programNotification" => { + let owner_option = extract_program_notification_owner(result); + let owner = match owner_option { + Some(owner) => owner, + None => return "signal.program_notification.generic".to_string(), + }; + if owner == crate::SPL_TOKEN_PROGRAM_ID.to_string() { + return "signal.program_notification.spl_token".to_string(); + } + if owner == crate::SPL_TOKEN_2022_PROGRAM_ID.to_string() { + return "signal.program_notification.spl_token_2022".to_string(); + } + "signal.program_notification.generic".to_string() + } + _ => format!( + "signal.{}", + method.replace("Notification", "").to_lowercase() + ), + } +} + +/// Builds a more specific severity for one WebSocket notification. +fn build_signal_severity_for_notification( + method: &str, + result: &serde_json::Value, +) -> crate::KbAnalysisSignalSeverity { + match method { + "programNotification" => crate::KbAnalysisSignalSeverity::Medium, + "accountNotification" => crate::KbAnalysisSignalSeverity::Low, + "logsNotification" => { + let lines = extract_logs_lines(result); + for line in &lines { + if line.contains("Instruction: InitializeMint") { + return crate::KbAnalysisSignalSeverity::Medium; + } + } + crate::KbAnalysisSignalSeverity::Low + } + "signatureNotification" => { + let err_option = extract_signature_notification_err(result); + match err_option { + Some(err) => { + if err.is_null() { + crate::KbAnalysisSignalSeverity::Low + } else { + crate::KbAnalysisSignalSeverity::Medium + } + } + None => crate::KbAnalysisSignalSeverity::Low, + } + } + _ => crate::KbAnalysisSignalSeverity::Low, + } +} + #[cfg(test)] mod tests { async fn create_database() -> crate::KbDatabase { @@ -409,6 +682,46 @@ mod tests { } } + fn build_logs_notification() -> crate::KbJsonRpcWsNotification { + crate::KbJsonRpcWsNotification { + jsonrpc: "2.0".to_string(), + method: "logsNotification".to_string(), + params: crate::KbJsonRpcWsNotificationParams { + result: serde_json::json!({ + "context": { + "slot": 888888_u64 + }, + "value": { + "signature": "Sig111111111111111111111111111111111111111111111111", + "err": null, + "logs": [ + "Program log: Instruction: InitializeMint" + ] + } + }), + subscription: 3001_u64, + }, + } + } + + fn build_signature_notification() -> crate::KbJsonRpcWsNotification { + crate::KbJsonRpcWsNotification { + jsonrpc: "2.0".to_string(), + method: "signatureNotification".to_string(), + params: crate::KbJsonRpcWsNotificationParams { + result: serde_json::json!({ + "context": { + "slot": 999999_u64 + }, + "value": { + "err": null + } + }), + subscription: 4001_u64, + }, + } + } + #[tokio::test] async fn slot_notification_records_observation() { let database = create_database().await; @@ -500,4 +813,90 @@ mod tests { "Mint111111111111111111111111111111111111111" ); } + + #[tokio::test] + async fn logs_notification_records_observation_and_signal() { + let database = create_database().await; + let persistence = crate::KbDetectionPersistenceService::new(std::sync::Arc::new(database)); + let detector = crate::KbSolanaWsDetectionService::new(persistence); + let outcome_result = detector + .process_notification( + Some("helius_primary_ws_programs".to_string()), + &build_logs_notification(), + ) + .await; + let outcome = match outcome_result { + Ok(outcome) => outcome, + Err(error) => panic!("process_notification failed: {error}"), + }; + match outcome { + crate::KbSolanaWsDetectionOutcome::ObservationRecorded { observation_id } => { + assert!(observation_id > 0); + } + _ => panic!("unexpected detection outcome"), + } + let observations_result = + crate::list_recent_onchain_observations(detector.persistence().database().as_ref(), 10) + .await; + let observations = match observations_result { + Ok(observations) => observations, + Err(error) => panic!("list_recent_onchain_observations failed: {error}"), + }; + let signals_result = + crate::list_recent_analysis_signals(detector.persistence().database().as_ref(), 10) + .await; + let signals = match signals_result { + Ok(signals) => signals, + Err(error) => panic!("list_recent_analysis_signals failed: {error}"), + }; + assert_eq!(observations.len(), 1); + assert_eq!(signals.len(), 1); + assert_eq!( + signals[0].signal_kind, + "signal.logs_notification.initialize_mint" + ); + } + + #[tokio::test] + async fn signature_notification_records_observation_and_signal() { + let database = create_database().await; + let persistence = crate::KbDetectionPersistenceService::new(std::sync::Arc::new(database)); + let detector = crate::KbSolanaWsDetectionService::new(persistence); + let outcome_result = detector + .process_notification( + Some("mainnet_public_ws_slots".to_string()), + &build_signature_notification(), + ) + .await; + let outcome = match outcome_result { + Ok(outcome) => outcome, + Err(error) => panic!("process_notification failed: {error}"), + }; + match outcome { + crate::KbSolanaWsDetectionOutcome::ObservationRecorded { observation_id } => { + assert!(observation_id > 0); + } + _ => panic!("unexpected detection outcome"), + } + let observations_result = + crate::list_recent_onchain_observations(detector.persistence().database().as_ref(), 10) + .await; + let observations = match observations_result { + Ok(observations) => observations, + Err(error) => panic!("list_recent_onchain_observations failed: {error}"), + }; + let signals_result = + crate::list_recent_analysis_signals(detector.persistence().database().as_ref(), 10) + .await; + let signals = match signals_result { + Ok(signals) => signals, + Err(error) => panic!("list_recent_analysis_signals failed: {error}"), + }; + assert_eq!(observations.len(), 1); + assert_eq!(signals.len(), 1); + assert_eq!( + signals[0].signal_kind, + "signal.signature_notification.confirmed" + ); + } }